@gradial/aci 0.1.26 → 0.1.28

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agentic Content Infrastructure SDK
2
2
 
3
- `@gradial/aci` `0.1.21` is a pre-1.0 public-surface reset for the ACI
3
+ `@gradial/aci` `0.1.27` is a pre-1.0 public-surface reset for the ACI
4
4
  client SDK. Breaking changes from the old `0.1.x` accidental surface are
5
5
  intentional: the root import is framework-neutral, framework runtime behavior is
6
6
  behind explicit subpaths, and the first required customer path is React/Next.
@@ -8,7 +8,7 @@ behind explicit subpaths, and the first required customer path is React/Next.
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- npm install @gradial/aci@0.1.21 zod@^4
11
+ npm install @gradial/aci@0.1.27 zod@^4
12
12
  ```
13
13
 
14
14
  ## Root Import
@@ -44,10 +44,27 @@ Next integration helpers live under explicit Next subpaths:
44
44
 
45
45
  ```ts
46
46
  import { createPage, withAci } from '@gradial/aci/next';
47
- import { createMiddleware } from '@gradial/aci/next/middleware';
47
+ import { createGradialMiddleware } from '@gradial/aci/next/middleware';
48
48
  import { DevRefresh } from '@gradial/aci/next/dev-refresh';
49
49
  ```
50
50
 
51
+ Use `ACI_SITE_ID` for the deployed site identity. Passing `siteId` to
52
+ `createGradialMiddleware()` is still supported, but the default template relies
53
+ on the environment so server and middleware resolution behave the same way.
54
+
55
+ Middleware release-scopes document routes by default, including files such as
56
+ `/robots.txt` and `/sitemap.xml`. Next internals, API routes, `favicon.ico`,
57
+ public files discovered from `public/`, and `/gradial/assets/*` are left to
58
+ Next. App-owned routes can opt out through `next.config`:
59
+
60
+ ```js
61
+ import { withAci } from '@gradial/aci/next/config';
62
+
63
+ export default withAci(nextConfig, {
64
+ passthroughRoutes: ['/healthz', '/checkout/:path*'],
65
+ });
66
+ ```
67
+
51
68
  Astro public subpath is `@gradial/aci/astro`.
52
69
 
53
70
  ## Server-Side Content Providers
@@ -68,13 +85,13 @@ go through an app-owned HTTP endpoint backed server-side by a `ContentProvider`.
68
85
  The first RC scaffold path is Next:
69
86
 
70
87
  ```bash
71
- npx @gradial/aci@0.1.21 init my-site --template next-starter
88
+ npx @gradial/aci@0.1.27 init my-site --template next-starter
72
89
  ```
73
90
 
74
91
  List available templates:
75
92
 
76
93
  ```bash
77
- npx @gradial/aci@0.1.21 init --list
94
+ npx @gradial/aci@0.1.27 init --list
78
95
  ```
79
96
 
80
97
  ## Verify Locally
Binary file
@@ -8,9 +8,9 @@ export async function GET(_request, { params }) {
8
8
  if (assetPath === '/') {
9
9
  return new Response('asset not found', { status: 404 });
10
10
  }
11
- if (process.env.ACI_CONTENT_PROVIDER === 'file') {
12
- const provider = new FileContentProvider();
13
- return provider.fetchRaw(assetPath);
11
+ const local = await localAssetResponse(assetPath);
12
+ if (local) {
13
+ return local;
14
14
  }
15
15
  try {
16
16
  const releaseId = await resolveReleaseId();
@@ -20,3 +20,16 @@ export async function GET(_request, { params }) {
20
20
  return new Response('asset not found', { status: 404 });
21
21
  }
22
22
  }
23
+ async function localAssetResponse(assetPath) {
24
+ const localRoot = process.env.ACI_LOCAL_ASSET_ROOT;
25
+ if (localRoot) {
26
+ const response = await new FileContentProvider(localRoot).fetchRaw(assetPath);
27
+ if (response.ok)
28
+ return response;
29
+ }
30
+ if (process.env.ACI_CONTENT_PROVIDER !== 'file') {
31
+ return null;
32
+ }
33
+ const response = await new FileContentProvider().fetchRaw(assetPath);
34
+ return response.ok ? response : null;
35
+ }
@@ -2,5 +2,13 @@ import type { NextConfig } from 'next';
2
2
  export interface WithAciOptions {
3
3
  /** Asset rewrite prefix. Defaults to '/.gradial-dam' */
4
4
  assetPrefix?: string;
5
+ /**
6
+ * Customer-owned routes that ACI middleware should leave to Next.
7
+ *
8
+ * Use exact paths such as '/healthz' or simple Next-style catch-alls such as
9
+ * '/checkout/:path*'. CMS document routes such as '/robots.txt' and
10
+ * '/sitemap.xml' should be omitted so they stay release-scoped.
11
+ */
12
+ passthroughRoutes?: string[];
5
13
  }
6
14
  export declare function withAci(nextConfig?: NextConfig, options?: WithAciOptions): NextConfig;
@@ -5,6 +5,7 @@ import { createContentWatchWebpackPlugin } from './content-watch.js';
5
5
  const require = createRequire(import.meta.url);
6
6
  export function withAci(nextConfig = {}, options = {}) {
7
7
  const publicPaths = collectPublicPaths(join(process.cwd(), 'public'));
8
+ const passthroughRoutes = normalizePassthroughRoutes(options.passthroughRoutes ?? []);
8
9
  const assetPrefix = options.assetPrefix || '/.gradial-dam';
9
10
  const userRewrites = nextConfig.rewrites;
10
11
  const userHeaders = nextConfig.headers;
@@ -62,6 +63,7 @@ export function withAci(nextConfig = {}, options = {}) {
62
63
  const webpack = require('webpack');
63
64
  config.plugins.push(new webpack.DefinePlugin({
64
65
  '__ACI_PUBLIC_PATHS__': JSON.stringify(publicPaths),
66
+ '__ACI_PASSTHROUGH_ROUTES__': JSON.stringify(passthroughRoutes),
65
67
  }));
66
68
  return typeof userWebpack === 'function'
67
69
  ? userWebpack(config, context)
@@ -69,6 +71,25 @@ export function withAci(nextConfig = {}, options = {}) {
69
71
  },
70
72
  };
71
73
  }
74
+ function normalizePassthroughRoutes(routes) {
75
+ const out = [];
76
+ const seen = new Set();
77
+ for (const route of routes) {
78
+ const clean = normalizeRoutePattern(route);
79
+ if (!clean || seen.has(clean))
80
+ continue;
81
+ seen.add(clean);
82
+ out.push(clean);
83
+ }
84
+ return out;
85
+ }
86
+ function normalizeRoutePattern(route) {
87
+ const trimmed = route.trim();
88
+ if (!trimmed)
89
+ return '';
90
+ const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
91
+ return withSlash.replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
92
+ }
72
93
  function collectPublicPaths(publicDir) {
73
94
  if (!existsSync(publicDir))
74
95
  return [];
@@ -2,3 +2,5 @@ export { DevRefresh, type DevRefreshProps } from './dev-refresh.js';
2
2
  export { withAci, type WithAciOptions } from './config.js';
3
3
  export { isPreviewMode } from './preview-mode.js';
4
4
  export { createPage, type CreatePageOptions, type SlotPropsFn } from './page.js';
5
+ export { getSiteConfig, getFragment, getLayout, getPage, getRenderInput, routeFromNextParams } from './server.js';
6
+ export { PageNotFoundError, FragmentNotFoundError, LayoutNotFoundError } from './server.js';
@@ -2,3 +2,5 @@ export { DevRefresh } from './dev-refresh.js';
2
2
  export { withAci } from './config.js';
3
3
  export { isPreviewMode } from './preview-mode.js';
4
4
  export { createPage } from './page.js';
5
+ export { getSiteConfig, getFragment, getLayout, getPage, getRenderInput, routeFromNextParams } from './server.js';
6
+ export { PageNotFoundError, FragmentNotFoundError, LayoutNotFoundError } from './server.js';
@@ -3,6 +3,7 @@ import { getUncachedEdgeConfigValue } from './edge-config.js';
3
3
  import { PREVIEW_TOKEN_COOKIE, PREVIEW_RELEASE_COOKIE, extractPreviewToken, resolvePreviewContext, createPreviewCookies, previewSignKey, } from '../preview/core.js';
4
4
  const RELEASE_SCOPED_PREFIX = '/__r';
5
5
  const PUBLIC_PATHS = new Set(typeof __ACI_PUBLIC_PATHS__ !== 'undefined' ? __ACI_PUBLIC_PATHS__ : []);
6
+ const PASSTHROUGH_ROUTES = (typeof __ACI_PASSTHROUGH_ROUTES__ !== 'undefined' ? __ACI_PASSTHROUGH_ROUTES__ : []);
6
7
  export function createGradialMiddleware(config = {}) {
7
8
  return async function middleware(request) {
8
9
  const url = request.nextUrl;
@@ -17,6 +18,9 @@ export function createGradialMiddleware(config = {}) {
17
18
  if (PUBLIC_PATHS.has(url.pathname)) {
18
19
  return NextResponse.next();
19
20
  }
21
+ if (isPassthroughRoute(url.pathname)) {
22
+ return NextResponse.next();
23
+ }
20
24
  // Block direct access to /__r/... — only middleware rewrites may use it.
21
25
  if (isReleaseScopedPath(url.pathname, RELEASE_SCOPED_PREFIX)) {
22
26
  return new Response('not found', {
@@ -32,6 +36,12 @@ export function createGradialMiddleware(config = {}) {
32
36
  if (process.env.ACI_CONTENT_PROVIDER === 'file') {
33
37
  return NextResponse.next();
34
38
  }
39
+ if (!siteIdForConfig(config)) {
40
+ return new Response('ACI site id is not configured', {
41
+ status: 500,
42
+ headers: { 'Cache-Control': 'private, no-store' },
43
+ });
44
+ }
35
45
  return new Response('no active release', {
36
46
  status: 503,
37
47
  headers: { 'Cache-Control': 'private, no-store' },
@@ -123,7 +133,7 @@ async function resolvePreviewFromRequest(request, config) {
123
133
  console.warn('[aci/mw] GRADIAL_PREVIEW_SIGN_KEY not set — preview tokens will be ignored');
124
134
  return null;
125
135
  }
126
- return await resolvePreviewContext({ token, siteId: config.siteId || '', signKey });
136
+ return await resolvePreviewContext({ token, siteId: siteIdForConfig(config), signKey });
127
137
  }
128
138
  function cookieMap(cookies) {
129
139
  const map = new Map();
@@ -134,19 +144,23 @@ function cookieMap(cookies) {
134
144
  }
135
145
  async function activeReleaseFromEdgeConfig(config) {
136
146
  const edgeConfig = config.edgeConfig || process.env.EDGE_CONFIG || '';
137
- if (!edgeConfig || !config.siteId)
147
+ const siteId = siteIdForConfig(config);
148
+ if (!edgeConfig || !siteId)
138
149
  return '';
139
- const active = await activeReleasePointer(edgeConfig, config.siteId);
150
+ const active = await activeReleasePointer(edgeConfig, siteId);
140
151
  if (!active.releaseId)
141
152
  return '';
142
153
  const deploymentId = config.deploymentId || process.env.VERCEL_DEPLOYMENT_ID || '';
143
154
  if (!deploymentId)
144
155
  return active.releaseId;
145
- const compatibility = await getEdgeConfigJSON(edgeConfig, deploymentCompatibilityKey(config.siteId, deploymentId));
156
+ const compatibility = await getEdgeConfigJSON(edgeConfig, deploymentCompatibilityKey(siteId, deploymentId));
146
157
  if (!compatibility)
147
158
  return active.releaseId;
148
159
  return clampRelease(active.releaseId, active.sequence || 0, compatibility);
149
160
  }
161
+ function siteIdForConfig(config) {
162
+ return config.siteId || process.env.ACI_SITE_ID || '';
163
+ }
150
164
  async function getEdgeConfigJSON(edgeConfig, key) {
151
165
  const value = await getEdgeConfigValue(edgeConfig, key);
152
166
  if (!value)
@@ -202,6 +216,31 @@ function clampRelease(activeReleaseId, activeSequence, compatibility) {
202
216
  function edgeKeySegment(value) {
203
217
  return value.replace(/[^A-Za-z0-9_-]/g, '_');
204
218
  }
219
+ function isPassthroughRoute(pathname) {
220
+ return PASSTHROUGH_ROUTES.some((route) => routeMatches(pathname, route));
221
+ }
222
+ function routeMatches(pathname, route) {
223
+ const cleanPath = normalizeRoutePath(pathname);
224
+ const cleanRoute = normalizeRoutePath(route);
225
+ if (!cleanRoute)
226
+ return false;
227
+ if (cleanRoute.endsWith('/:path*')) {
228
+ const prefix = cleanRoute.slice(0, -'/:path*'.length) || '/';
229
+ return cleanPath === prefix || cleanPath.startsWith(`${prefix}/`);
230
+ }
231
+ if (cleanRoute.endsWith('/*')) {
232
+ const prefix = cleanRoute.slice(0, -'/*'.length) || '/';
233
+ return cleanPath === prefix || cleanPath.startsWith(`${prefix}/`);
234
+ }
235
+ return cleanPath === cleanRoute;
236
+ }
237
+ function normalizeRoutePath(value) {
238
+ const trimmed = value.trim();
239
+ if (!trimmed)
240
+ return '';
241
+ const pathname = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
242
+ return pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
243
+ }
205
244
  export const config = {
206
- matcher: ['/((?!_next/).*)'],
245
+ matcher: ['/((?!_next(?:/|$)|api(?:/|$)|gradial/assets(?:/|$)|favicon\\.ico$).*)'],
207
246
  };
@@ -60,7 +60,14 @@ export class FileContentProvider {
60
60
  }
61
61
  }
62
62
  async manifest() {
63
- this.#manifest ??= this.readJSON('manifest.json');
63
+ this.#manifest ??= this.readJSON('manifest.json').catch(() => ({
64
+ manifestVersion: '0',
65
+ buildDigest: '',
66
+ siteConfigRef: '',
67
+ routes: {},
68
+ fragments: {},
69
+ layouts: {},
70
+ }));
64
71
  return await this.#manifest;
65
72
  }
66
73
  async fetchRaw(key) {
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ export type TaxonomyAssignments = Record<string, string[]>;
2
3
  type InferDataProps<TSchema> = TSchema extends z.ZodType<infer T> ? T : Record<string, unknown>;
3
4
  export interface DataModelDefinition<TSchema = unknown, TType extends string = string> {
4
5
  type: TType;
@@ -11,6 +12,7 @@ export interface DataModelContract<TSchema = unknown, TType extends string = str
11
12
  export interface DataEntry<TProps = Record<string, unknown>, TType extends string = string> {
12
13
  $type: TType;
13
14
  props: TProps;
15
+ taxonomy?: TaxonomyAssignments;
14
16
  }
15
17
  export type InferDataModelProps<TContract> = TContract extends DataModelContract<infer TSchema, string> ? InferDataProps<TSchema> : Record<string, unknown>;
16
18
  export type InferDataModelType<TContract> = TContract extends DataModelContract<unknown, infer TType> ? TType : string;
@@ -1,9 +1,11 @@
1
1
  import type { VaryDimension } from './render-mode.js';
2
+ import type { TaxonomyAssignments } from './data.js';
2
3
  export interface PageDocument {
3
4
  path: string;
4
5
  layout: string;
5
6
  locale: string;
6
7
  metadata: PageMetadata;
8
+ taxonomy?: TaxonomyAssignments;
7
9
  regions: Record<string, RegionNode>;
8
10
  }
9
11
  export interface PageMetadata {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -74,6 +74,11 @@
74
74
  "types": "./dist/next/dev-refresh.d.ts",
75
75
  "import": "./dist/next/dev-refresh.js"
76
76
  },
77
+ "./next/server": {
78
+ "types": "./dist/next/server.d.ts",
79
+ "import": "./dist/next/server.js",
80
+ "default": "./dist/next/server.js"
81
+ },
77
82
  "./astro": {
78
83
  "types": "./dist/astro/index.d.ts",
79
84
  "import": "./dist/astro/index.js",
package/src/types/data.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ export type TaxonomyAssignments = Record<string, string[]>;
4
+
3
5
  type InferDataProps<TSchema> = TSchema extends z.ZodType<infer T>
4
6
  ? T
5
7
  : Record<string, unknown>;
@@ -19,6 +21,7 @@ export interface DataModelContract<TSchema = unknown, TType extends string = str
19
21
  export interface DataEntry<TProps = Record<string, unknown>, TType extends string = string> {
20
22
  $type: TType;
21
23
  props: TProps;
24
+ taxonomy?: TaxonomyAssignments;
22
25
  }
23
26
 
24
27
  export type InferDataModelProps<TContract> = TContract extends DataModelContract<infer TSchema, string>
package/src/types/page.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { VaryDimension } from './render-mode.js';
2
+ import type { TaxonomyAssignments } from './data.js';
2
3
 
3
4
  export interface PageDocument {
4
5
  path: string;
5
6
  layout: string;
6
7
  locale: string;
7
8
  metadata: PageMetadata;
9
+ taxonomy?: TaxonomyAssignments;
8
10
  regions: Record<string, RegionNode>;
9
11
  }
10
12
 
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@astrojs/node": "^10.1.4",
20
- "@gradial/aci": "0.1.23",
20
+ "@gradial/aci": "{{aciVersion}}",
21
21
  "astro": "^6.1.9",
22
22
  "zod": "^4.0.0"
23
23
  },
@@ -22,6 +22,11 @@ Skills live in `.agents/skills/`. Each has a `SKILL.md` entry point and
22
22
  - Registry lives in `src/cms/registry.ts` (`createRegistry` with `[contract, component]` tuples).
23
23
  - Compiled output goes to `.aci/compiled/` (generated, not checked in).
24
24
  - Framework integration via `withAci()` in the framework config.
25
+ - Next.js server functions from `@gradial/aci/next` — use `getSiteConfig()`, `getPage()`, `getFragment()` instead of importing `FileContentProvider` or `S3ContentProvider` directly.
26
+ - Rich text from `@gradial/aci/react` — use `RichText` component, never raw `marked`/`dangerouslySetInnerHTML`.
27
+ - Images from `@gradial/aci` — use `imageAttrs()` or SDK components, never manual `srcSet` construction.
28
+ - Block slots use `blockRefArray()` in contracts and `renderChildren` in components — never hardcoded component name matching.
29
+ - No `as any` casts — use `ContentProps` from contracts.
25
30
  - Supported frameworks: Astro and Next.js (SvelteKit removed).
26
31
 
27
32
  ## Commands
@@ -588,3 +588,34 @@ chrome-specific override scopes, and token creation workflow.
588
588
  Read `references/repo-structure.md` for the full annotated directory
589
589
  layout, framework-specific paths, naming conventions, and generated
590
590
  files list.
591
+
592
+ ## Deep Dive: SDK Server Functions
593
+
594
+ Read `references/sdk-server-functions.md` for `@gradial/aci/next`
595
+ server-side functions (`createPage`, `getSiteConfig`, `getFragment`,
596
+ etc.), the three-tier content resolution chain, and the critical rule
597
+ against importing `FileContentProvider` or `S3ContentProvider`
598
+ directly in app code.
599
+
600
+ ## Deep Dive: Block Slots
601
+
602
+ Read `references/block-slots.md` for `blockRefArray()` in contracts,
603
+ `renderChildren` in components, and the rules for nested block
604
+ rendering without hardcoded component name matching.
605
+
606
+ ## Deep Dive: Rich Text
607
+
608
+ Read `references/rich-text.md` for the SDK `RichText` component from
609
+ `@gradial/aci/react`, sanitization policy, and why you should never
610
+ use `marked` or `dangerouslySetInnerHTML` directly.
611
+
612
+ ## Deep Dive: Asset Rendering
613
+
614
+ Read `references/asset-rendering.md` for `imageAttrs()`, `Picture`,
615
+ `Image`, `Media` components, and CSS background image patterns.
616
+
617
+ ## Deep Dive: Type Safety
618
+
619
+ Read `references/type-safety.md` for `ContentProps` from contracts,
620
+ `as any` prohibition, CSS custom property typing, site config
621
+ generics, and block slot type imports.
@@ -0,0 +1,110 @@
1
+ # Asset Rendering Reference
2
+
3
+ ## Rules
4
+
5
+ - **NEVER** construct `<img>` tags manually with `src`/`srcSet` from
6
+ image sources.
7
+ - **ALWAYS** use the SDK's `imageAttrs()` function from `@gradial/aci`
8
+ or the `Image`/`Picture` component from `@gradial/aci/react`.
9
+ - **NEVER** duplicate the `Image` type locally — always import from
10
+ `@gradial/aci`.
11
+
12
+ ## SDK Exports
13
+
14
+ ### From `@gradial/aci`
15
+
16
+ | Export | Type | Purpose |
17
+ |--------|------|---------|
18
+ | `Image` | type | The DAM-backed image type (`$type`, `assetId`, `versionId`, `alt`, `sources`) |
19
+ | `ImageSource` | type | A single responsive derivative (`src`, `width`, `height`, `type`) |
20
+ | `imageAttrs(image, overrides?)` | function | Returns a complete attributes object for `<img>` |
21
+ | `primaryImageSource(image)` | function | Picks the largest-width source from an image |
22
+ | `ImageSchema` | Zod schema | Schema for image fields in contracts |
23
+
24
+ ### From `@gradial/aci/react`
25
+
26
+ | Export | Type | Purpose |
27
+ |--------|------|---------|
28
+ | `Image` | component | Renders an `Image` as `<img>` with computed attrs |
29
+ | `Picture` | component | Renders an `Image` as responsive `<img>` (delegates to `imageAttrs()`) |
30
+ | `Media` | component | Renders media assets (images, videos) with type detection |
31
+
32
+ ## imageAttrs()
33
+
34
+ `imageAttrs(image, overrides?)` returns a complete attributes object
35
+ including `src`, `alt`, `width`, `height`, `srcSet`, `sizes`, `loading`,
36
+ and `decoding`. Spread the result into a native `<img>` element:
37
+
38
+ ```tsx
39
+ import { imageAttrs, type Image } from '@gradial/aci';
40
+
41
+ export function HeroImage({ image }: { image: Image }) {
42
+ return <img {...imageAttrs(image)} />;
43
+ }
44
+ ```
45
+
46
+ Override specific attributes by passing a second argument:
47
+
48
+ ```tsx
49
+ <img {...imageAttrs(image, { loading: 'eager' })} />
50
+ ```
51
+
52
+ ## SDK Components
53
+
54
+ ```tsx
55
+ import { Picture, Image } from '@gradial/aci/react';
56
+
57
+ // Picture: delegates to imageAttrs() internally
58
+ <Picture image={image} />
59
+
60
+ // Image: same behavior, accepts image or value prop
61
+ <Image image={image} />
62
+ ```
63
+
64
+ Both components call `imageAttrs()` internally and spread the result
65
+ into a native `<img>` element.
66
+
67
+ ## CSS Background Images
68
+
69
+ For CSS background images, use `primaryImageSource()` to get the URL:
70
+
71
+ ```tsx
72
+ import { primaryImageSource } from '@gradial/aci';
73
+
74
+ export function HeroBackground({ image }: { image: Image }) {
75
+ const src = primaryImageSource(image).src;
76
+ return (
77
+ <div style={{ backgroundImage: `url(${src})` } as React.CSSProperties} />
78
+ );
79
+ }
80
+ ```
81
+
82
+ ## In Contracts
83
+
84
+ Use `ImageSchema` for DAM-managed image fields:
85
+
86
+ ```ts
87
+ import { ImageSchema, defineComponentContract } from '@gradial/aci';
88
+ import { z } from 'zod';
89
+
90
+ export const schema = z.object({
91
+ image: ImageSchema,
92
+ cards: z.array(z.object({
93
+ image: ImageSchema.optional(),
94
+ })),
95
+ });
96
+ ```
97
+
98
+ Declare `imageSlots` matching every `ImageSchema` field, including
99
+ nested dot paths like `cards.image`. See
100
+ `references/image-assets.md` for full image slot configuration.
101
+
102
+ ## Anti-Patterns
103
+
104
+ - Manually building `srcSet` strings from `image.sources` — use
105
+ `imageAttrs()` instead
106
+ - Constructing `<img src={image.sources[0].src} />` — use `imageAttrs()`
107
+ or SDK components
108
+ - Duplicating the `Image` type locally — import from `@gradial/aci`
109
+ - Ignoring `width`/`height` — `imageAttrs()` includes them to prevent
110
+ layout shift
@@ -0,0 +1,140 @@
1
+ # Block Slots Reference
2
+
3
+ ## What Block Slots Are
4
+
5
+ Container components (like `container` and `grid_column`) can render
6
+ child blocks. The SDK detects block slots from contracts and injects a
7
+ `renderChildren` callback into the component props at render time.
8
+
9
+ This lets editors nest blocks inside other blocks in content JSON, and
10
+ the SDK resolves the child components through the registry — no
11
+ hardcoded component name matching needed.
12
+
13
+ ## Defining Block Slots in Contracts
14
+
15
+ Use `blockRefArray()` from `@gradial/aci` in the contract's Zod schema
16
+ to declare a block slot field. The compiler scans the schema for
17
+ `blockRefArray` and `blockRef` fields and auto-discovers block slots —
18
+ no separate `slots` or `blockSlots` declaration needed.
19
+
20
+ ```ts
21
+ import { blockRefArray, defineComponentContract } from '@gradial/aci';
22
+ import { z } from 'zod';
23
+ import { calloutContract } from './callout.contract';
24
+ import { headingContract } from './heading.contract';
25
+
26
+ export const schema = z.object({
27
+ theme: z.enum(['auto', 'light', 'dark']).optional(),
28
+ className: z.string().optional(),
29
+ blocks: blockRefArray([calloutContract, headingContract]).optional(),
30
+ });
31
+
32
+ export type ContentProps = z.infer<typeof schema>;
33
+
34
+ export const containerContract = defineComponentContract({
35
+ id: 'container',
36
+ props: schema,
37
+ });
38
+ ```
39
+
40
+ `blockRefArray()` takes an array of allowed contracts — only those block
41
+ types can be placed in the slot. This flows into the generated JSON
42
+ schema, so the content validator enforces the allowlist at build time.
43
+
44
+ For a single (non-array) block slot, use `blockRef(contracts)` instead:
45
+
46
+ ```ts
47
+ import { blockRef, defineComponentContract } from '@gradial/aci';
48
+ import { headingContract } from './heading.contract';
49
+
50
+ export const schema = z.object({
51
+ heading: blockRef([headingContract]).optional(),
52
+ });
53
+ ```
54
+
55
+ ## Using renderChildren in Components
56
+
57
+ The SDK's render system checks `entry.contract.blockSlots` and, if
58
+ present, injects a `renderChildren` callback into the component props.
59
+ This callback resolves child blocks through the registry.
60
+
61
+ ```tsx
62
+ import type { BlockRef, ReactElement } from '@gradial/aci';
63
+ import type { RenderChildren } from '@gradial/aci/react';
64
+ import type { ContentProps } from '@/cms/contracts/components/container.contract';
65
+
66
+ export async function ContainerSection({
67
+ theme = 'auto',
68
+ className = '',
69
+ blocks = [],
70
+ renderChildren,
71
+ }: ContentProps & { renderChildren?: RenderChildren<readonly BlockRef[]> }) {
72
+ const rendered = blocks.length && renderChildren
73
+ ? await renderChildren(blocks)
74
+ : null;
75
+
76
+ return (
77
+ <section className={`theme-${theme} py-[var(--spacing-section-y)] ${className}`}>
78
+ <div className="flex flex-col gap-6">
79
+ {rendered}
80
+ </div>
81
+ </section>
82
+ );
83
+ }
84
+ ```
85
+
86
+ The `RenderChildren` type is exported from `@gradial/aci/react`:
87
+
88
+ ```ts
89
+ type RenderChildren<T extends readonly BlockRef[]> = (blocks: T) => Promise<ReactElement[]>;
90
+ ```
91
+
92
+ ## Rules
93
+
94
+ - **Never hardcode component name matching.** No switch statements on
95
+ `block.component`. Always use `renderChildren` from the SDK — it uses
96
+ the registry to resolve components.
97
+ - **Always use `blockRefArray()` in contracts** to declare block slot
98
+ fields. The compiler auto-discovers them from the Zod schema.
99
+ - **Atomic block contracts must be registered** in the registry with
100
+ their components. `renderChildren` resolves child blocks through the
101
+ same registry used for top-level blocks.
102
+ - **`blockRefArray()` takes an array of allowed contracts** — only
103
+ those block types can be placed in the slot. This is enforced at
104
+ content validation time.
105
+ - **`renderChildren` is optional** — always guard with a conditional
106
+ (`blocks.length && renderChildren ? await renderChildren(blocks) : null`)
107
+ so the component works in isolation (e.g., Storybook).
108
+ - **Nested blocks live inside `props`**, not in a sibling `slots` field.
109
+ The content JSON places child blocks directly in the prop value
110
+ matching the `blockRefArray` field name.
111
+
112
+ ## Content JSON Example
113
+
114
+ ```json
115
+ {
116
+ "id": "cta-section",
117
+ "component": "container",
118
+ "props": {
119
+ "theme": "dark",
120
+ "blocks": [
121
+ {
122
+ "id": "primary-cta",
123
+ "component": "callout",
124
+ "props": {
125
+ "headline": "Get started today",
126
+ "body": "Ship content updates in minutes."
127
+ }
128
+ },
129
+ {
130
+ "id": "heading",
131
+ "component": "heading",
132
+ "props": {
133
+ "text": "Why teams choose us",
134
+ "level": 2
135
+ }
136
+ }
137
+ ]
138
+ }
139
+ }
140
+ ```
@@ -0,0 +1,75 @@
1
+ # Rich Text Reference
2
+
3
+ ## Rules
4
+
5
+ - **NEVER** use `marked`, `markdown-it`, or `dangerouslySetInnerHTML`
6
+ directly in component code.
7
+ - **ALWAYS** use the SDK's `RichText` component from `@gradial/aci/react`.
8
+
9
+ The SDK `RichText` component uses `sanitize-html` with an explicit
10
+ allowlist. It renders markdown via `markdown-it` (with `html: false` to
11
+ prevent raw HTML in markdown) and then sanitizes the output. Links get
12
+ `rel="noopener noreferrer"` automatically.
13
+
14
+ ## Usage
15
+
16
+ ```tsx
17
+ import { RichText } from '@gradial/aci/react';
18
+
19
+ export function ArticleBody({ body }: { body: string }) {
20
+ return <RichText value={body} />;
21
+ }
22
+ ```
23
+
24
+ The `value` prop accepts:
25
+
26
+ - **`string`** — treated as markdown and rendered to HTML
27
+ - **`RichTextValue`** object — `{ format: 'markdown', markdown: '...' }`
28
+ or `{ format: 'html', html: '...' }`
29
+
30
+ When a string is passed, the component wraps it as
31
+ `{ format: 'markdown', markdown: value }` internally.
32
+
33
+ ## In Contracts
34
+
35
+ Use `RichTextValueSchema` for typed rich text fields:
36
+
37
+ ```ts
38
+ import { RichTextValueSchema } from '@gradial/aci';
39
+ import { z } from 'zod';
40
+
41
+ export const schema = z.object({
42
+ body: RichTextValueSchema.optional(),
43
+ });
44
+ ```
45
+
46
+ Or accept a plain string:
47
+
48
+ ```ts
49
+ export const schema = z.object({
50
+ body: z.string().optional(),
51
+ });
52
+ ```
53
+
54
+ Both forms work with the `RichText` component — it accepts
55
+ `RichTextValue | string`.
56
+
57
+ ## Sanitization Policy
58
+
59
+ The SDK allowlist includes:
60
+
61
+ - Standard formatting tags: `p`, `br`, `strong`, `em`, `ul`, `ol`, `li`,
62
+ `code`, `pre`, `blockquote`, `h1`–`h6`, `img`
63
+ - Links (`a`) with `href`, `name`, `target`, `rel` attributes
64
+ - Images (`img`) with `src`, `alt`, `width`, `height`, `loading`
65
+ attributes
66
+
67
+ Raw HTML in markdown input is rejected by `markdown-it`'s `html: false`
68
+ setting. HTML format values are sanitized by `sanitize-html`.
69
+
70
+ ## Anti-Patterns
71
+
72
+ - Importing `marked` or `markdown-it` directly — the SDK already
73
+ handles this with proper sanitization
74
+ - Using `dangerouslySetInnerHTML` with unsanitized content
75
+ - Writing a custom markdown-to-HTML pipeline in component code
@@ -0,0 +1,192 @@
1
+ # SDK Server Functions Reference
2
+
3
+ ## `@gradial/aci/next` Exports
4
+
5
+ The SDK provides server-side functions that handle content resolution
6
+ across preview, local file, and deployed S3 modes. **Always use these
7
+ functions instead of directly importing `FileContentProvider` or
8
+ `S3ContentProvider`.** The SDK handles the resolution chain automatically.
9
+
10
+ ### `createPage(registry)`
11
+
12
+ The catch-all route handler for Next.js App Router. Owns param
13
+ normalization, content loading via render input, metadata generation,
14
+ `notFound` handling, layout resolution, and block rendering via the
15
+ registry.
16
+
17
+ ```tsx
18
+ // src/app/[[...slug]]/page.tsx
19
+ import { createPage } from '@gradial/aci/next';
20
+ import { registry } from '@/cms/registry';
21
+
22
+ const page = createPage(registry);
23
+ export const dynamic = 'force-dynamic';
24
+ export const generateMetadata = page.generateMetadata;
25
+ export default page.default;
26
+ ```
27
+
28
+ `createPage` loads the page's layout, resolves all slots (filling from
29
+ page regions or layout fragment defaults), and renders blocks via the
30
+ registry. The `main` slot is wrapped in `<main>`; other slots render as
31
+ fragments.
32
+
33
+ ### `withAci(nextConfig, options?)`
34
+
35
+ Next.js config wrapper. Sets up asset rewrites (`.gradial-dam` prefix)
36
+ and dev-mode content watch via webpack plugin.
37
+
38
+ ```ts
39
+ // next.config.ts
40
+ import { withAci } from '@gradial/aci/next';
41
+ import type { NextConfig } from 'next';
42
+
43
+ const nextConfig: NextConfig = { reactStrictMode: true };
44
+ export default withAci(nextConfig);
45
+ ```
46
+
47
+ ### `getSiteConfig<T>()`
48
+
49
+ Loads the site configuration. Checks the render input header first
50
+ (preview mode), then falls back to file or S3 provider. **MUST be used
51
+ in `layout.tsx` instead of `new FileContentProvider()`.**
52
+
53
+ ```tsx
54
+ import { getSiteConfig } from '@gradial/aci/next';
55
+
56
+ const siteConfig = await getSiteConfig<MySiteConfig>();
57
+ ```
58
+
59
+ Generic: pass your site config interface for typed access:
60
+ `getSiteConfig<SiteConfig>()`.
61
+
62
+ ### `getFragment(name)`
63
+
64
+ Loads a fragment by name. Checks render input first (preview mode), then
65
+ provider fallback.
66
+
67
+ ### `getLayout(name)`
68
+
69
+ Loads a layout by name. Checks render input first (preview mode), then
70
+ provider fallback.
71
+
72
+ ### `getPage(route)`
73
+
74
+ Loads page content for a specific route. Checks render input first
75
+ (preview mode), then provider fallback.
76
+
77
+ ### `getRenderInput(route)`
78
+
79
+ Loads the full render input (page + siteConfig + domain + locale). Used
80
+ internally by `createPage`. Available for custom server composition when
81
+ you need the full render context.
82
+
83
+ ### `routeFromNextParams(params)`
84
+
85
+ Normalizes Next.js catch-all params (`{ slug?: string[] }`) into a route
86
+ string. Handles `Promise`-wrapped params from Next.js 15+.
87
+
88
+ ### `isPreviewMode()`
89
+
90
+ Async function returning the preview release ID string when in preview
91
+ mode, or an empty string when not. Use as a truthy check:
92
+
93
+ ```tsx
94
+ const previewRelease = await isPreviewMode();
95
+ if (previewRelease) { /* preview-specific logic */ }
96
+ ```
97
+
98
+ ### `DevRefresh`
99
+
100
+ React component for dev-mode content refresh. Connects to the SDK's
101
+ content watch WebSocket and triggers a page reload when compiled content
102
+ changes. Add to the root layout:
103
+
104
+ ```tsx
105
+ import { DevRefresh } from '@gradial/aci/next';
106
+
107
+ // In layout body:
108
+ <DevRefresh />
109
+ ```
110
+
111
+ Only active in development (`NODE_ENV !== 'production'`). Renders `null`
112
+ in production.
113
+
114
+ ### Error Classes
115
+
116
+ | Class | Thrown When |
117
+ |-------|-------------|
118
+ | `PageNotFoundError` | Page not found for a route |
119
+ | `FragmentNotFoundError` | Fragment not found by name |
120
+ | `LayoutNotFoundError` | Layout not found by name |
121
+
122
+ `createPage` catches `PageNotFoundError` and calls `notFound()`
123
+ automatically.
124
+
125
+ ## Three-Tier Content Resolution
126
+
127
+ Every SDK server function follows the same resolution chain:
128
+
129
+ ### Tier 1: Render Input (Preview Mode)
130
+
131
+ When the Go harness sends a render request, it sets the
132
+ `x-gradial-render-input-id` header. The SDK checks the
133
+ `pendingRenderInputs` in-process map for a matching render input and
134
+ returns page data directly from the Go harness's render payload. This
135
+ is how preview works — the harness pushes content into the Next.js
136
+ process without touching S3 or disk.
137
+
138
+ ### Tier 2: File Provider (`ACI_CONTENT_PROVIDER=file`)
139
+
140
+ When the `ACI_CONTENT_PROVIDER` environment variable is set to `file`,
141
+ the SDK reads compiled content from `.aci/compiled/` on disk. This is
142
+ the local development path. Also used as a fallback in preview mode when
143
+ no render input header is present.
144
+
145
+ ### Tier 3: S3 Provider (Deployed SSR)
146
+
147
+ When neither render input nor file provider is active, the SDK reads
148
+ from S3. The release ID is resolved from (in order):
149
+
150
+ 1. `x-gradial-release-id` header (set by middleware)
151
+ 2. `ACI_RELEASE_ID` environment variable
152
+ 3. Vercel Edge Config active release pointer
153
+
154
+ ## Critical Rule
155
+
156
+ **NEVER import `FileContentProvider` or `S3ContentProvider` directly in
157
+ app code.** Always use `getPage()`, `getSiteConfig()`, `getFragment()`,
158
+ `getLayout()`, or `getRenderInput()` from `@gradial/aci/next`. The SDK
159
+ handles the resolution chain automatically.
160
+
161
+ The only exception is the asset route handler
162
+ (`src/app/gradial/assets/[releaseId]/[...path]/route.ts`), which directly
163
+ uses `FileContentProvider` for local asset serving and
164
+ `getReleaseAssetResponse` for S3 assets.
165
+
166
+ ## Layout.tsx Pattern
167
+
168
+ ```tsx
169
+ import { DevRefresh, getSiteConfig } from '@gradial/aci/next';
170
+
171
+ export default async function Layout({ children }: { children: ReactNode }) {
172
+ const siteConfig = await getSiteConfig<SiteConfig>().catch(() => null);
173
+ return (
174
+ <html lang="en">
175
+ <body>
176
+ {children}
177
+ <DevRefresh />
178
+ </body>
179
+ </html>
180
+ );
181
+ }
182
+ ```
183
+
184
+ Chrome (Header/Footer) is CMS-managed via layout fragment defaults and
185
+ rendered by `createPage` through the registry. The `layout.tsx` file
186
+ should be minimal — import CSS, optionally load site config for
187
+ metadata/fonts, and render `<DevRefresh />`.
188
+
189
+ For sites that need site config in the layout (e.g., for fonts or
190
+ metadata), use `getSiteConfig<SiteConfig>()` with a `.catch(() => null)`
191
+ fallback so the layout doesn't crash if content isn't available during
192
+ build.
@@ -0,0 +1,141 @@
1
+ # Type Safety Reference
2
+
3
+ ## ContentProps from Contracts
4
+
5
+ Contracts export `ContentProps = z.infer<typeof schema>`. Use this type
6
+ in runtime components for prop typing:
7
+
8
+ ```ts
9
+ // src/cms/contracts/components/hero.contract.ts
10
+ import { defineComponentContract, ImageSchema } from '@gradial/aci';
11
+ import { z } from 'zod';
12
+
13
+ export const schema = z.object({
14
+ headline: z.string().min(1),
15
+ image: ImageSchema,
16
+ ctaLabel: z.string().optional(),
17
+ ctaHref: z.string().optional(),
18
+ });
19
+
20
+ export type ContentProps = z.infer<typeof schema>;
21
+
22
+ export const heroContract = defineComponentContract({
23
+ id: 'hero',
24
+ props: schema,
25
+ });
26
+ ```
27
+
28
+ ```tsx
29
+ // src/components/sections/Hero.tsx
30
+ import type { ContentProps } from '@/cms/contracts/components/hero.contract';
31
+
32
+ type HeroProps = ContentProps & { className?: string };
33
+
34
+ export function Hero({ headline, image, ctaLabel, ctaHref, className }: HeroProps) {
35
+ // ...
36
+ }
37
+ ```
38
+
39
+ ## Rules
40
+
41
+ ### No `as any` casts
42
+
43
+ **NEVER** use `as any` or `as any[]` casts in component code. If the
44
+ type doesn't fit, fix the contract schema.
45
+
46
+ ```tsx
47
+ // BAD
48
+ const items = props.items as any[];
49
+
50
+ // GOOD — fix the schema to match the actual shape
51
+ export const schema = z.object({
52
+ items: z.array(z.object({
53
+ title: z.string(),
54
+ description: z.string(),
55
+ })),
56
+ });
57
+ ```
58
+
59
+ ### No passthrough schemas
60
+
61
+ Contracts must define proper schemas. Do not use
62
+ `z.object({}).passthrough()` or `z.record(z.any())`:
63
+
64
+ ```ts
65
+ // BAD — hides type errors and allows any content
66
+ export const schema = z.object({}).passthrough();
67
+ export const schema = z.record(z.any());
68
+
69
+ // GOOD — explicit fields
70
+ export const schema = z.object({
71
+ headline: z.string(),
72
+ items: z.array(z.object({ title: z.string() })),
73
+ });
74
+ ```
75
+
76
+ ### CSS custom properties
77
+
78
+ For React `style` props with CSS custom properties, use
79
+ `as React.CSSProperties` (not `as any`):
80
+
81
+ ```tsx
82
+ // BAD
83
+ <div style={{ '--accent': 'red' } as any} />
84
+
85
+ // GOOD
86
+ <div style={{ '--accent': 'red' } as React.CSSProperties} />
87
+ ```
88
+
89
+ ### Image type
90
+
91
+ Use the `Image` type from `@gradial/aci` for image props. Never
92
+ duplicate the type locally:
93
+
94
+ ```tsx
95
+ // BAD — local duplicate
96
+ interface Image { src: string; alt: string; }
97
+
98
+ // GOOD — import from SDK
99
+ import type { Image } from '@gradial/aci';
100
+ ```
101
+
102
+ ### Site config
103
+
104
+ Define a `SiteConfig` interface and pass it to `getSiteConfig()`:
105
+
106
+ ```tsx
107
+ import { getSiteConfig } from '@gradial/aci/next';
108
+
109
+ interface SiteConfig {
110
+ title: string;
111
+ domain: string;
112
+ defaultLocale: string;
113
+ navigation: NavItem[];
114
+ footer: FooterConfig;
115
+ }
116
+
117
+ const config = await getSiteConfig<SiteConfig>();
118
+ // config is typed as SiteConfig
119
+ ```
120
+
121
+ ### Block slot types
122
+
123
+ Use `RenderChildren` from `@gradial/aci/react` and `BlockRef` from
124
+ `@gradial/aci` for block slot component props:
125
+
126
+ ```tsx
127
+ import type { BlockRef } from '@gradial/aci';
128
+ import type { RenderChildren } from '@gradial/aci/react';
129
+ import type { ContentProps } from '@/cms/contracts/components/container.contract';
130
+
131
+ export async function ContainerSection({
132
+ blocks = [],
133
+ renderChildren,
134
+ }: ContentProps & {
135
+ renderChildren?: RenderChildren<readonly BlockRef[]>;
136
+ }) {
137
+ // ...
138
+ }
139
+ ```
140
+
141
+ See `references/block-slots.md` for the full block slot pattern.
@@ -17,7 +17,7 @@
17
17
  "build:storybook": "storybook build"
18
18
  },
19
19
  "dependencies": {
20
- "@gradial/aci": "0.1.23",
20
+ "@gradial/aci": "{{aciVersion}}",
21
21
  "next": "^15.1.6",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
@@ -1,4 +1,7 @@
1
1
  import { createGradialMiddleware } from '@gradial/aci/next/middleware';
2
2
 
3
3
  export default createGradialMiddleware();
4
- export { config } from '@gradial/aci/next/middleware';
4
+
5
+ export const config = {
6
+ matcher: ['/((?!_next(?:/|$)|api(?:/|$)|gradial/assets(?:/|$)|favicon\\.ico$).*)'],
7
+ };