# @riverbankcms/sdk

A lightweight TypeScript SDK for consuming Riverbank CMS content in React Server Components, Next.js, and client-side React applications.

## Features

- ✅ **Server-side rendering** with `loadPage()` helper
- ✅ **Unified content fetching** with `loadContent()` for pages and entries
- ✅ **Client-side data fetching** with `usePage()` and `useContent()` hooks
- ✅ **Automatic data prefetching** for blocks with loaders
- ✅ **Custom block data loaders** - Config-based (CMS endpoints) and code-based (external APIs)
- ✅ **Type-safe API client** with caching
- ✅ **React Server Components** compatible
- ✅ **Custom blocks** - Define site-specific blocks with full CMS editing support
- ✅ **Content tooling** - Define content type schema in config and sync pages, entries, and navigation through explicit content push scopes
- ✅ **Site URLs** - Configure preview and live URLs for dashboard integration

## Installation

```bash
npm install @riverbankcms/sdk
# or
pnpm add @riverbankcms/sdk
```

## Quick Start

> **Important:** The `baseUrl` parameter must be the complete API URL including the `/api` path. For example: `https://dashboard.example.com/api`

### Server-Side Rendering (Recommended)

Use in Next.js App Router Server Components or getServerSideProps:

```tsx
import { createRiverbankClient, loadPage, Page } from '@riverbankcms/sdk';

// Note: baseUrl must include the /api path
const client = createRiverbankClient({
  apiKey: process.env.RIVERBANK_API_KEY!,
  baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});

export default async function HomePage() {
  // Fetch all page data (site, page, block data) in parallel
  const pageData = await loadPage({
    client,
    siteId: 'your-site-id',
    path: '/',
  });

  return <Page {...pageData} />;
}
```

### Client-Side Rendering

For client-only React apps or Client Components:

```tsx
"use client";

import { createRiverbankClient } from '@riverbankcms/sdk';
import { usePage, Page } from '@riverbankcms/sdk/client';

const client = createRiverbankClient({
  apiKey: process.env.NEXT_PUBLIC_RIVERBANK_API_KEY!,
  baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});

export function DynamicPage({ path }: { path: string }) {
  const pageData = usePage({ client, siteId: 'your-site-id', path });

  if (pageData.loading) return <div>Loading...</div>;
  if (pageData.error) return <div>Error: {pageData.error.message}</div>;

  return <Page {...pageData} />;
}
```

## API Reference

### Configuration

```ts
const client = createRiverbankClient({
  apiKey: string;        // Required: Builder API key
  baseUrl: string;       // Required: Full API URL (e.g., 'https://dashboard.example.com/api')
  cache?: {
    enabled?: boolean;   // Default: true
    ttl?: number;        // Default: 300 (seconds)
    maxSize?: number;    // Default: 100
  };
  resilience?: ResilienceConfig;  // See "Resilience & Caching" section
});
```

### Resilience & Caching

The SDK provides a multi-layer resilience strategy to keep your site running even during CMS outages:

1. **In-Memory Cache** - Fast cache with configurable TTL
2. **Stale-If-Error** - Serves stale cached data when live fetch fails
3. **Circuit Breaker** - Fail-fast after repeated failures (5 by default)
4. **Prebuild Fallback** - Static build artifacts as last resort (site, pages, entries, navigation, forms)
5. **Retry with Backoff** - Automatic retry for transient errors

**Basic Configuration:**

```ts
const client = createRiverbankClient({
  apiKey: process.env.RIVERBANK_API_KEY!,
  baseUrl: process.env.RIVERBANK_BASE_URL!,
  cache: {
    enabled: true,
    ttl: 300,           // 5 minutes fresh cache
    maxSize: 100,
  },
  resilience: {
    enabled: true,
    staleIfError: true,
    staleTtlSec: 300,   // Additional 5 minutes for stale data
    prebuildDir: '.riverbank-cache',  // Enable prebuild fallback
    maxPrebuildAgeSec: 86400,         // 24 hours max prebuild age
  },
});
```

**Monitoring Callbacks:**

```ts
resilience: {
  // Called on every request with status information
  onStatusChange: (status) => {
    console.log('SDK request:', {
      source: status.source,        // 'live' | 'cache' | 'stale' | 'prebuild' | 'error'
      circuit: status.circuit.state, // 'closed' | 'open' | 'half-open'
      staleAgeSec: status.staleAgeSec,
      prebuildAgeSec: status.prebuildAgeSec,
      error: status.error?.message,
    });
  },

  // Called only when entering/exiting degraded mode
  onDegradedMode: (degraded, status) => {
    if (degraded) {
      console.warn('CMS degraded mode:', status.source);
      // Alert monitoring system
    }
  },
}
```

**Programmatic State Access:**

```ts
// Get the last request status
const lastStatus = client.getLastEmittedStatus();

// Get current circuit breaker state
const circuitState = client.getCircuitState();
// { state: 'closed', failureCount: 0 }
```

**Full Configuration Options:**

| Option | Default | Description |
|--------|---------|-------------|
| `resilience.enabled` | `true` | Enable resilience features |
| `resilience.staleIfError` | `true` | Serve stale data on failure |
| `resilience.staleTtlSec` | `300` | Additional stale window after cache TTL |
| `resilience.requestTimeoutMs` | `8000` | Request timeout (5000 in browser) |
| `resilience.prebuildDir` | - | Path to prebuild cache (enables fallback) |
| `resilience.maxPrebuildAgeSec` | `86400` | Maximum prebuild age (24h) |
| `resilience.retry.maxAttempts` | `3` | Total attempts including initial |
| `resilience.retry.baseDelayMs` | `200` | Base delay between retries |
| `resilience.retry.maxDelayMs` | `2000` | Maximum retry delay |
| `resilience.circuitBreaker.failureThreshold` | `5` | Failures before circuit opens |
| `resilience.circuitBreaker.resetTimeoutMs` | `30000` | Time before half-open |

For detailed testing and verification steps, see [`docs/resilience-verification.md`](./docs/resilience-verification.md).

### Headless Appointment Booking

SDK sites that need bespoke appointment booking UI can use the headless booking runtime from `@riverbankcms/sdk/bookings`. See [`docs/headless-booking.md`](./docs/headless-booking.md) for the real `blockOverrides` pattern with an `'appointment-booking'` override, `HeadlessBookingProvider`, `getAppointmentBookingOverrideInput`, and `useHeadlessBookingFlow`.

### Server-Side API

```tsx
import { loadPage, Page } from '@riverbankcms/sdk';

// Fetch all page data
const pageData = await loadPage({
  client,
  siteId: string,
  path: string,
  preview?: boolean,           // Default: false - set true for draft content
  pageId?: string,             // Optional: explicit page ID
  dataLoaderOverrides?: {      // Optional: code-based data loaders for custom blocks
    'custom.block-id': {
      loaderKey: async (ctx) => fetchData(ctx.content.fieldValue),
    },
  },
});

// Render the page
<Page {...pageData} />
```

### Preview Mode (Draft Content)

The SDK supports fetching draft/unpublished content by passing `preview: true`. This requires an API key with site access.

**Server-Side Preview:**

```tsx
// app/preview/[[...slug]]/page.tsx
import { createRiverbankClient, loadPage, Page } from '@riverbankcms/sdk';

const client = createRiverbankClient({
  apiKey: process.env.RIVERBANK_API_KEY!,
  baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});

export default async function PreviewPage({ params, searchParams }) {
  const pageData = await loadPage({
    client,
    siteId: searchParams.siteId,
    path: `/${params.slug?.join('/') || ''}`,
    preview: true, // 🔑 Fetch draft content
  });

  return (
    <div>
      <div className="preview-banner">Preview Mode - Draft Content</div>
      <Page {...pageData} />
    </div>
  );
}
```

**Client-Side Preview:**

```tsx
"use client";

import { createRiverbankClient } from '@riverbankcms/sdk';
import { usePage, Page } from '@riverbankcms/sdk/client';

const client = createRiverbankClient({
  apiKey: process.env.NEXT_PUBLIC_RIVERBANK_API_KEY!,
  baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});

export function PreviewPage({ siteId, path }: { siteId: string; path: string }) {
  const pageData = usePage({
    client,
    siteId,
    path,
    preview: true, // 🔑 Fetch draft content
  });

  if (pageData.loading) return <div>Loading preview...</div>;
  if (pageData.error) return <div>Error: {pageData.error.message}</div>;

  return <Page {...pageData} />;
}
```

**How Preview Works:**

- ✅ Uses your existing API key (no separate preview tokens needed)
- ✅ Returns draft content for blocks (via `draftContent` field)
- ✅ Shows unpublished pages that are in draft state
- ✅ Published and preview content are cached separately
- ✅ Requires API key to be scoped to the site

**Use Cases:**

- Preview pages before publishing
- Share draft content with stakeholders
- Test content changes in staging environment
- Content editing workflows

### Client-Side API

```tsx
import { usePage, Page } from '@riverbankcms/sdk/client';

const pageData = usePage({
  client,
  siteId: string,
  path: string,
  preview?: boolean,  // Default: false - set true for draft content
  pageId?: string,
});

// pageData is a discriminated union:
// { loading: true, error: null, ... } |
// { loading: false, error: Error, ... } |
// { loading: false, error: null, page, theme, ... }
```

## Unified Content Loading (Pages + Entries)

When a path might resolve to either a page or a content entry (blog post, product, etc.), use `loadContent()` or `useContent()` instead of the page-specific functions.

### Server-Side: loadContent

```tsx
import { loadContent, isPageContent, Page } from '@riverbankcms/sdk';

export default async function DynamicRoute({ params }) {
  const path = `/${params.slug?.join('/') || ''}`;

  const content = await loadContent({
    client,
    siteId: 'your-site-id',
    path,
    preview: false, // Set true for draft content
  });

  // Pages get rendered with the Page component
  if (isPageContent(content)) {
    return <Page {...content} />;
  }

  // Entries get rendered with custom UI
  return (
    <article>
      <h1>{content.entry.title}</h1>
      <div>{JSON.stringify(content.entry.content)}</div>
    </article>
  );
}
```

### Client-Side: useContent

```tsx
"use client";

import { useContent, isPageContentResult, Page } from '@riverbankcms/sdk/client';

export function DynamicContent({ path }: { path: string }) {
  const content = useContent({
    client,
    siteId: 'your-site-id',
    path,
    preview: false,
  });

  if (content.loading) return <div>Loading...</div>;
  if (content.error) return <div>Error: {content.error.message}</div>;

  if (isPageContentResult(content)) {
    return <Page page={content.page} theme={content.theme} siteId={content.siteId} resolvedData={content.resolvedData} />;
  }

  // Render entry with custom UI
  return (
    <article>
      <h1>{content.entry.title}</h1>
      <p>Type: {content.entry.type}</p>
      <div>{JSON.stringify(content.entry.content)}</div>
    </article>
  );
}
```

### Entry-Specific Rendering

Route entries to different components based on their content type:

```tsx
import { loadContent, isPageContent, Page } from '@riverbankcms/sdk';

export default async function DynamicRoute({ params }) {
  const content = await loadContent({ client, siteId, path: `/${params.slug?.join('/') || ''}` });

  if (isPageContent(content)) {
    return <Page {...content} />;
  }

  // Route to type-specific components
  switch (content.entry.type) {
    case 'blog-post':
      return <BlogPost entry={content.entry} theme={content.theme} />;
    case 'product':
      return <ProductPage entry={content.entry} theme={content.theme} />;
    case 'event':
      return <EventPage entry={content.entry} theme={content.theme} />;
    default:
      return <GenericEntry entry={content.entry} />;
  }
}

function BlogPost({ entry, theme }) {
  const { title, content, metaDescription } = entry;
  return (
    <article>
      <h1>{title}</h1>
      <p className="lead">{metaDescription}</p>
      <div className="prose">{content.body}</div>
      <p>By {content.author} on {new Date(entry.createdAt).toLocaleDateString()}</p>
    </article>
  );
}
```

### ContentEntryData Type

When working with entries, you receive raw content data:

```ts
type ContentEntryData = {
  id: string;
  type: string | null;        // Content type key (e.g., 'blog-post', 'product')
  title: string;
  slug: string | null;
  path: string | null;
  status: string;
  publishAt: string | null;
  content: Record<string, unknown>;  // Raw content fields
  metaTitle: string | null;
  metaDescription: string | null;
  createdAt: string;
  updatedAt: string;
};
```

### When to Use Which Function

| Function | Use Case |
|----------|----------|
| `loadPage()` / `usePage()` | Pages only - throws error on entries |
| `loadContent()` / `useContent()` | Both pages and entries - discriminated union |

## Fetching Multiple Entries

Use `client.getEntries()` to fetch lists of content entries for blog listing pages, product catalogs, etc.

### Basic Usage

```tsx
import { createRiverbankClient } from '@riverbankcms/sdk';

const client = createRiverbankClient({
  apiKey: process.env.RIVERBANK_API_KEY!,
  baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});

// Fetch all published blog posts
const { entries } = await client.getEntries({
  siteId: 'your-site-id',
  contentType: 'blog-post',
});
```

### Pagination and Sorting

```tsx
// Fetch latest 10 posts (newest first)
const { entries: latestPosts } = await client.getEntries({
  siteId: 'your-site-id',
  contentType: 'blog-post',
  limit: 10,
  order: 'newest',
});

// Fetch oldest posts first (for archives)
const { entries: archivedPosts } = await client.getEntries({
  siteId: 'your-site-id',
  contentType: 'blog-post',
  order: 'oldest',
});

// Include draft entries for preview mode
const { entries: allPosts } = await client.getEntries({
  siteId: 'your-site-id',
  contentType: 'blog-post',
  preview: true, // Includes unpublished drafts
});
```

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `siteId` | string | Yes | Site ID |
| `contentType` | string | Yes | Content type key (e.g., 'blog-post') |
| `limit` | number | No | Maximum entries to return |
| `order` | 'newest' \| 'oldest' | No | Sort by publish date |
| `preview` | boolean | No | Include draft entries |

### Real-World Example: Blog Listing Page

```tsx
// app/blog/page.tsx
import { createRiverbankClient } from '@riverbankcms/sdk';
import Link from 'next/link';

const client = createRiverbankClient({
  apiKey: process.env.RIVERBANK_API_KEY!,
  baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});

export default async function BlogPage() {
  const site = await client.getSite({ slug: 'my-site' });

  const { entries: posts } = await client.getEntries({
    siteId: site.site.id,
    contentType: 'blog-post',
    limit: 10,
    order: 'newest',
  });

  return (
    <main>
      <h1>Blog</h1>
      <div className="grid gap-6">
        {posts.map((post) => (
          <article key={post.id}>
            <Link href={`/blog/${post.slug}`}>
              <h2>{post.title}</h2>
            </Link>
            <p>{post.metaDescription}</p>
            <time>{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString() : 'Draft'}</time>
          </article>
        ))}
      </div>
    </main>
  );
}
```

## Import Patterns

⚠️ **Important:** Use the correct import path for your environment:

```tsx
// ✅ Server Components (Next.js App Router)
import { createRiverbankClient, loadPage, Page } from '@riverbankcms/sdk';

// ✅ Client Components (use "use client" directive)
"use client";
import { usePage, Page } from '@riverbankcms/sdk/client';
```

The `/client` subpath exports React hooks that require client-side React features (useState, useEffect). The main entry point only exports server-safe code.

## Navigation

The SDK provides navigation utilities for transforming CMS navigation data into render-ready structures. All navigation functions return nested structures that support both direct links and dropdown menus.

### Navigation Types

Navigation items are discriminated unions using a `kind` field:

```typescript
import type {
  NavItem,        // NavLink | NavDropdown
  NavLink,        // Direct link with href
  NavDropdown,    // Dropdown container with children
} from '@riverbankcms/sdk/navigation';

// NavLink - a direct navigation link
type NavLink = {
  kind: 'link';
  id: string;
  label: string;
  href: string;
  isExternal: boolean;
};

// NavDropdown - a dropdown menu container
type NavDropdown = {
  kind: 'dropdown';
  id: string;
  label: string;
  children: NavLink[];  // Max 1 level of nesting
};

// NavItem - either a link or dropdown
type NavItem = NavLink | NavDropdown;
```

### Type Guards

Use the provided type guards for safe type narrowing:

```typescript
import { isNavLink, isNavDropdown } from '@riverbankcms/sdk/navigation';

const items = getPrimaryNavItems(siteData.navigation, {
  routes: siteData.routes,
  pagesByIdentifier: siteData.pagesByIdentifier,
  entriesByIdentifier: siteData.entriesByIdentifier,
  portalEnabled: siteData.portalEnabled ?? false,
  hasPortalSession: false,
});

items.forEach(item => {
  if (isNavLink(item)) {
    // TypeScript knows this is NavLink
    console.log(item.href);
  } else if (isNavDropdown(item)) {
    // TypeScript knows this is NavDropdown
    console.log(`${item.label} has ${item.children.length} children`);
  }
});
```

Or use the `kind` discriminator directly:

```typescript
items.forEach(item => {
  if (item.kind === 'link') {
    console.log(item.href);
  } else {
    // item.kind === 'dropdown'
    console.log(item.children);
  }
});
```

### Navigation Helper Functions

```typescript
import {
  getPrimaryNavItems,
  getNavItemsBySlug,
  getPrimaryNavigation,
  getNavigationBySlug,
  transformToNavItems,
} from '@riverbankcms/sdk/navigation';

const navOptions = {
  routes: siteData.routes,
  pagesByIdentifier: siteData.pagesByIdentifier,
  entriesByIdentifier: siteData.entriesByIdentifier,
  portalEnabled: siteData.portalEnabled ?? false,
  hasPortalSession: false,
};

// Get nav items from the primary menu (marked isPrimary, or first menu)
const headerNav = getPrimaryNavItems(siteData.navigation, navOptions);

// Get nav items from a specific menu by name/slug
const footerNav = getNavItemsBySlug(siteData.navigation, 'footer', navOptions);

// Get the raw menu object (for accessing menu metadata)
const primaryMenu = getPrimaryNavigation(siteData.navigation);
const footerMenu = getNavigationBySlug(siteData.navigation, 'footer');

// Transform a menu object to nav items
const items = transformToNavItems(primaryMenu, navOptions);
```

### Building Menu and Logo

For rendering, use `buildMenu` and `buildLogo` which resolve hrefs from the route map and canonical identifier maps:

```typescript
import type { Menu, Logo } from '@riverbankcms/sdk/navigation';
import { buildMenu, buildLogo } from '@riverbankcms/sdk/navigation';

// Build menu with pre-resolved hrefs from route map
const menu = buildMenu(siteData.navigation, siteData.routes, {
  pagesByIdentifier: siteData.pagesByIdentifier,
  entriesByIdentifier: siteData.entriesByIdentifier,
  portalEnabled: siteData.portalEnabled ?? false,
  hasPortalSession: false,
});

// menu.items is NavItem[] (links and dropdowns)
// menu.ctaItem is NavLink | null (CTA is always a link, never dropdown)

const logo = buildLogo(siteData.layout.logo, siteData.site.title);
```

Portal links are opt-in at render time: if `portalEnabled` is not set to `true`, portal items are filtered out.

### Rendering Navigation with Dropdowns

```tsx
import { getPrimaryNavItems, isNavLink, isNavDropdown } from '@riverbankcms/sdk/navigation';

function Header({ siteData }) {
  const items = getPrimaryNavItems(siteData.navigation, {
    routes: siteData.routes,
    pagesByIdentifier: siteData.pagesByIdentifier,
    entriesByIdentifier: siteData.entriesByIdentifier,
    portalEnabled: false,
    hasPortalSession: false,
  });

  return (
    <nav>
      {items.map(item => {
        if (isNavLink(item)) {
          return (
            <a
              key={item.id}
              href={item.href}
              target={item.isExternal ? '_blank' : undefined}
              rel={item.isExternal ? 'noopener noreferrer' : undefined}
            >
              {item.label}
            </a>
          );
        }

        // Dropdown
        return (
          <div key={item.id} className="dropdown">
            <button>{item.label}</button>
            <div className="dropdown-menu">
              {item.children.map(child => (
                <a
                  key={child.id}
                  href={child.href}
                  target={child.isExternal ? '_blank' : undefined}
                >
                  {child.label}
                </a>
              ))}
            </div>
          </div>
        );
      })}
    </nav>
  );
}
```

### Rendering Menu in Components

```tsx
import { buildMenu, buildLogo } from '@riverbankcms/sdk/navigation';

function SiteHeader({ siteData }) {
  const menu = buildMenu(siteData.navigation, siteData.routes, {
    pagesByIdentifier: siteData.pagesByIdentifier,
    entriesByIdentifier: siteData.entriesByIdentifier,
    portalEnabled: siteData.portalEnabled ?? false,
    hasPortalSession: false,
  });
  const logo = buildLogo(siteData.layout.logo, siteData.site.title);

  return (
    <header>
      {logo && <img src={logo.src} alt={logo.alt} />}

      <nav>
        {menu.items.map(item => {
          if (item.kind === 'link') {
            return <a key={item.id} href={item.href}>{item.label}</a>;
          }

          return (
            <div key={item.id} className="dropdown">
              <span>{item.label}</span>
              <ul>
                {item.children.map(child => (
                  <li key={child.id}>
                    <a href={child.href}>{child.label}</a>
                  </li>
                ))}
              </ul>
            </div>
          );
        })}
      </nav>

      {menu.ctaItem && (
        <a href={menu.ctaItem.href} className="cta-button">
          {menu.ctaItem.label}
        </a>
      )}
    </header>
  );
}
```

### Navigation Exports

```typescript
import {
  // Helper functions
  getPrimaryNavItems,
  getNavItemsBySlug,
  getPrimaryNavigation,
  getNavigationBySlug,
  transformToNavItems,
  buildMenu,
  buildLogo,
  buildMenuViewModel,
  buildLogoViewModel,

  // Type guards
  isNavLink,
  isNavDropdown,

  // Types
  type NavItem,
  type NavLink,
  type NavDropdown,
  type Menu,
  type Logo,
  type MenuViewModel,
  type MenuLinkViewModel,
  type MenuCtaViewModel,
  type LogoViewModel,
  type LogoSource,
  type NavigationRenderOptions,
  type LinkValue,
  type InternalLinkValue,
  type ExternalLinkValue,
  type CustomLinkValue,
} from '@riverbankcms/sdk/navigation';

// For block content link types (page/entry identifier links)
import type {
  PageLinkValue,     // { kind: 'page', identifier: string }
  EntryLinkValue,    // { kind: 'entry', contentType: string, identifier: string }
} from '@riverbankcms/sdk/rendering';
```

## Layout Component

The `Layout` component renders the site header, footer, and wraps your page content. Use this when you want consistent site chrome across all pages.

`Layout` is a pure Server Component. The default `@riverbankcms/sdk/rendering` `Page` and `Block` surfaces hydrate CMS islands automatically, and `createCatchAllPage()` keeps doing the same for the canonical Next.js catch-all flow. If you intentionally use the strict `@riverbankcms/sdk/rendering/server` surface, mount `BlocksIslandsHydrator` explicitly when you need interactive CMS islands:

```tsx
import { Layout } from '@riverbankcms/sdk/rendering/server';

export default async function MyPage() {
  const siteData = await client.getSite({ slug: 'my-site' });

  return (
    <Layout siteData={siteData}>
      <main>Page content</main>
    </Layout>
  );
}
```

If you intentionally render hydratable CMS islands through the strict server surface, keep the bootstrap explicit:

```tsx
import { Layout } from '@riverbankcms/sdk/rendering/server';
import { BlocksIslandsHydrator } from '@riverbankcms/sdk/rendering/islands';

export default async function MyPage() {
  const siteData = await client.getSite({ slug: 'my-site' });

  return (
    <Layout siteData={siteData}>
      <BlocksIslandsHydrator siteId={siteData.site.id} supabaseUrl={siteData.supabaseUrl} />
      <main>Page content</main>
    </Layout>
  );
}
```

### Using Layout with Pre-Fetched Site Data

When you already have site data (from `loadPage` or `client.getSite`):

```tsx
import { Layout } from '@riverbankcms/sdk/rendering/server';

export default async function MyPage() {
  const siteData = await client.getSite({ slug: 'my-site' });

  return (
    <Layout siteData={siteData}>
      <main>
        <h1>My Custom Page</h1>
        <p>This content is wrapped with site header and footer.</p>
      </main>
    </Layout>
  );
}
```

### Automatic Site Fetching

Layout can fetch site data automatically if you provide a client and identifier:

```tsx
import { Layout } from '@riverbankcms/sdk';

export default async function MyPage() {
  return (
    <Layout
      client={client}
      slug="my-site"  // or siteId="..." or domain="example.com"
    >
      <main>
        <h1>My Custom Page</h1>
      </main>
    </Layout>
  );
}
```

### Customizing Header and Footer

You can hide the header or footer:

```tsx
<Layout
  siteData={siteData}
  header={false}  // Hide header
  footer={false}  // Hide footer
>
  <main>Landing page without navigation</main>
</Layout>
```

### Choosing Header Variants

Override the header variant from your code:

```tsx
<Layout
  siteData={siteData}
  headerVariant="centered"  // Options: classic, centered, transparent, floating, editorial
>
  <main>Content with centered header</main>
</Layout>
```

### Opting Out of Theme Styling (Headless)

If your SDK site brings its own global styles and you don't want the SDK to inject theme CSS variables, disable the theme scope wrapper:

```tsx
<Layout
  siteData={siteData}
  useThemeScope={false}
  header={(data) => <CustomHeader {...data} />}
  footer={() => <GlobalFooter />}
>
  <main>Fully custom styling</main>
</Layout>
```

### Fully Custom Headers

Create your own header while using CMS navigation data:

```tsx
import { Layout, type HeaderData } from '@riverbankcms/sdk';

function CustomHeader({ menu, logo, site }: HeaderData) {
  return (
    <header className="custom-header">
      <a href="/">{logo?.url && <img src={logo.url} alt={site.title} />}</a>
      <nav>
        {menu.items.map(item => (
          <a key={item.id} href={item.url.href}>{item.label}</a>
        ))}
      </nav>
    </header>
  );
}

export default async function MyPage() {
  const siteData = await client.getSite({ slug: 'my-site' });

  return (
    <Layout
      siteData={siteData}
      header={(data) => <CustomHeader {...data} />}
    >
      <main>Custom header with CMS navigation</main>
    </Layout>
  );
}
```

## Block Component

The `Block` component renders individual CMS blocks. Use this when you want to mix CMS content with custom JSX, or render blocks outside of a full page context. The default `@riverbankcms/sdk/rendering` `Block` surface hydrates interactive CMS islands automatically. If you import `Block` from `@riverbankcms/sdk/rendering/server`, you are choosing the strict bootstrap-free surface and must mount `BlocksIslandsHydrator` yourself for interactive blocks.

### Basic Block Rendering

```tsx
import { Block } from '@riverbankcms/sdk/rendering';

export default async function CustomPage() {
  const siteData = await client.getSite({ slug: 'my-site' });

  return (
    <div>
      <h1>Custom Header</h1>

      {/* Render a CMS block inline */}
      <Block
        blockKind="block.hero"
        content={{
          headline: 'Welcome',
          subheadline: 'To our custom page',
          cta: { label: 'Get Started', href: '/signup' }
        }}
        theme={siteData.theme}
        siteId={siteData.site.id}
      />

      <p>More custom content...</p>
    </div>
  );
}
```

### Block with Data Loading

Blocks can automatically load their data (e.g., blog posts, products) if they have data loaders configured:

```tsx
<Block
  blockKind="block.blog-listing"
  blockId="block-abc123"  // Block ID enables data loading
  content={{ maxPosts: 10 }}
  theme={siteData.theme}
  siteId={siteData.site.id}
  pageId={page.id}         // Optional: for context-aware loaders
  client={client}          // Required for data loading
/>
```

Interactive blocks such as forms, bookings, events, and commerce also need the CMS API base URL so the hydrated island can submit requests:

```tsx
import { getRiverbankEnv } from '@riverbankcms/sdk/env';

const { baseUrl } = getRiverbankEnv();

<Block
  blockKind="block.checkout"
  blockId="checkout-block"
  content={{ heading: 'Checkout' }}
  theme={siteData.theme}
  siteId={siteData.site.id}
  apiBaseUrl={baseUrl}
/>
```

### Advanced Block Options

```tsx
<Block
  blockKind="block.hero"
  blockId="block-xyz"
  content={{ heading: 'Welcome' }}
  theme={theme}
  siteId="site-123"

  // Optional data loading context
  pageId="page-456"
  previewStage="preview"  // or "published"
  client={client}

  // Editor-mid-edit fallback: when strict manifest validation fails (e.g.
  // a required field is temporarily empty), retry against a relaxed
  // validator over the same content so valid in-flight fields still
  // render instead of flipping the block to the red "Invalid content"
  // fallback. Leave unset (or `false`) for production frontends.
  usePlaceholders={true}
/>
```

### CTA Links with Page/Entry Identifiers

Block content can reference pages and content entries using stable identifiers instead of UUIDs. These links are resolved at render time:

```tsx
// Link to a page by identifier
const heroContent = {
  headline: 'Welcome',
  cta: {
    kind: 'page',
    identifier: 'pricing'  // Resolves to page with identifier 'pricing'
  }
};

// Link to a content entry (e.g., blog post, team member)
const featuredContent = {
  headline: 'Meet Our Team',
  cta: {
    kind: 'entry',
    contentType: 'team-member',
    identifier: 'jane-doe'  // Resolves to the team member entry
  }
};

// Traditional link formats still work
const externalCta = { kind: 'external', href: 'https://example.com' };
const simpleCta = { href: '/contact' };  // Simple href
```

When using `loadPage()`, identifier maps are automatically included in the response and passed to the `Page` component for resolution:

```tsx
// loadPage() returns pagesByIdentifier and entriesByIdentifier
const pageData = await loadPage({ client, siteId, path: '/' });

// Page component receives these automatically
<Page {...pageData} />
```

**Link Value Types:**
- `{ kind: 'page', identifier: string }` - Link to a page by its identifier
- `{ kind: 'entry', contentType: string, identifier: string }` - Link to a routable content entry
- `{ kind: 'internal', routeId: string, href: string, ... }` - Internal link with route ID (from link picker)
- `{ kind: 'external', href: string }` - External URL
- `{ kind: 'url', href: string }` - Custom URL
- `{ href: string }` - Simple href (pre-resolved)

## Image Resolution

Resolve `Media` objects to optimized image URLs. Essential for custom blocks that display images.

### Basic Usage

```tsx
import { resolveImageUrl, ImagePresets } from '@riverbankcms/sdk/rendering';
import type { Media } from '@riverbankcms/sdk/rendering';

function MyCustomBlock({ content }: { content: { image: Media } }) {
  // Use a preset for common sizes
  const imageUrl = resolveImageUrl(content.image, ImagePresets.card);

  return <img src={imageUrl} alt={content.image.alt ?? ''} />;
}
```

### Image Presets

Use presets for consistent, optimized image sizes:

```tsx
import { resolveImageUrl, ImagePresets } from '@riverbankcms/sdk/rendering';

// Thumbnail (200px width, 80% quality) - for lists, previews
resolveImageUrl(media, ImagePresets.thumbnail)

// Card (600px width, 80% quality) - for cards, grids
resolveImageUrl(media, ImagePresets.card)

// Hero (1200px width, 85% quality) - for above-fold content
resolveImageUrl(media, ImagePresets.hero)

// Full (1920px width, 85% quality) - for backgrounds, large displays
resolveImageUrl(media, ImagePresets.full)
```

### Custom Dimensions

Specify exact dimensions when presets don't fit:

```tsx
// Width only (height auto-calculated to maintain aspect ratio)
resolveImageUrl(media, { width: 800, quality: 85 })

// Height only (width auto-calculated)
resolveImageUrl(media, { height: 400, quality: 80 })

// Both dimensions
resolveImageUrl(media, { width: 800, height: 600, quality: 85 })
```

### Original Resolution

For rare cases like downloads or print, request the original:

```tsx
// Full resolution - no transforms applied
resolveImageUrl(media, { original: true })
```

### Complete Custom Block Example

```tsx
import { resolveImageUrl, ImagePresets, RichText } from '@riverbankcms/sdk/rendering';
import type { BlockOverrideProps, Media } from '@riverbankcms/sdk/rendering';

type TeamMemberContent = {
  name: string;
  role?: string;
  photo?: Media;
  bio?: string;
};

export function TeamMember({ content }: BlockOverrideProps<TeamMemberContent>) {
  const photoUrl = content.photo
    ? resolveImageUrl(content.photo, ImagePresets.card)
    : undefined;

  return (
    <div className="flex items-start gap-6 p-6">
      {photoUrl && (
        <img
          src={photoUrl}
          alt={content.photo?.alt || content.name}
          className="h-24 w-24 rounded-full object-cover"
        />
      )}
      <div>
        <h3 className="text-xl font-bold">{content.name}</h3>
        {content.role && <p className="text-gray-600">{content.role}</p>}
        {content.bio && <RichText content={content.bio} />}
      </div>
    </div>
  );
}
```

### How It Works

Images stored in Supabase are served via the transform endpoint when dimensions are specified:

```
# Without transforms (original resolution - 3000px+)
https://xxx.supabase.co/storage/v1/object/public/media/sites/abc/originals/photo.jpg

# With transforms (optimized - 600px)
https://xxx.supabase.co/storage/v1/render/image/public/media/sites/abc/originals/photo.jpg?width=600&quality=80
```

This reduces image payload significantly (often 10x smaller) while maintaining visual quality.

### Notes

- SVG files are returned as direct URLs (transforms don't apply to vectors)
- External URLs (`src` field) are returned as-is (can't be transformed)
- The runtime `Media` type may include `storagePath`, `storageBucket`, `src`, `alt`, `identifier`, and transform metadata, but **portable content JSON should persist identifier-only** (no `assetId`/storage paths/`src`).

## Metadata Generation

Generate SEO-optimized metadata for Next.js pages from Riverbank CMS data.

### Basic Usage

```tsx
import { generatePageMetadata } from '@riverbankcms/sdk/metadata';
import { loadPage } from '@riverbankcms/sdk';

export async function generateMetadata({ params }) {
  const pageData = await loadPage({ client, siteId, path: params.slug });
  const siteData = await client.getSite({ id: siteId });

  return generatePageMetadata({
    page: pageData.page,
    site: siteData.site,
    path: params.slug || '/',
    siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
  });
}
```

### With Custom Overrides

```tsx
const metadata = generatePageMetadata({
  page: pageData.page,
  site: siteData.site,
  path: '/',
  siteUrl: 'https://example.com',
  overrides: {
    title: 'Custom SEO Title',
    description: 'Custom SEO description with keywords',
    ogImage: 'https://example.com/og-image.jpg',
    canonicalUrl: 'https://example.com/canonical-path',
  },
  googleSiteVerification: 'verification-token',
});
```

### Preview Environment Metadata

```tsx
import { generatePreviewMetadata } from '@riverbankcms/sdk/metadata';

// Adds noindex/nofollow for staging environments
const metadata = generatePreviewMetadata({
  page: pageData.page,
  site: siteData.site,
  path: '/',
  siteUrl: 'https://preview.example.com',
});
```

## Route Resolution

Resolve URL paths to pages, redirects, or 404s for dynamic routing.

### Single Route Resolution

```tsx
import { resolveRoute } from '@riverbankcms/sdk/routing';
import { notFound, redirect } from 'next/navigation';

export default async function DynamicPage({ params }) {
  const path = `/${params.slug?.join('/') || ''}`;

  const resolution = await resolveRoute({
    client,
    siteId: 'your-site-id',
    path,
  });

  if (resolution.type === 'redirect') {
    redirect(resolution.destination);
  }

  if (resolution.type === 'not-found') {
    notFound();
  }

  return <Page {...resolution.pageData} />;
}
```

### Batch Route Resolution

```tsx
import { resolveRoutes } from '@riverbankcms/sdk/routing';

// Useful for sitemap generation or validation
const resolutions = await resolveRoutes({
  client,
  siteId: 'your-site-id',
  paths: ['/', '/about', '/services', '/contact'],
});

resolutions.forEach(({ path, resolution }) => {
  if (resolution.type === 'page') {
    console.log(`${path} → Page: ${resolution.pageData.page.name}`);
  } else if (resolution.type === 'redirect') {
    console.log(`${path} → Redirect to ${resolution.destination}`);
  } else {
    console.log(`${path} → Not found`);
  }
});
```

## Forms

Fetch form definitions for rendering in your frontend. Forms include schema (field definitions) and settings (submit behavior, notifications).

### Fetching Forms

```tsx
import { createRiverbankClient } from '@riverbankcms/sdk';

const client = createRiverbankClient({
  apiKey: process.env.RIVERBANK_API_KEY!,
  baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});

// Fetch all forms for a site
const { forms } = await client.getForms({
  siteId: 'your-site-id',
});

// Each form includes:
// - id: Form UUID
// - slug: URL-friendly identifier
// - name: Display name
// - schema: Field definitions
// - settings: Submit behavior, notifications
```

### Forms with Prebuild Fallback

Forms support the same resilience features as other content. When prebuild is configured, forms are cached at build time and served as a fallback when the CMS is unavailable:

```tsx
const client = createRiverbankClient({
  apiKey: process.env.RIVERBANK_API_KEY!,
  baseUrl: process.env.RIVERBANK_BASE_URL!,
  resilience: {
    prebuildDir: '.riverbank-cache',  // Forms are cached here
  },
});

// If CMS is down, cached forms are returned
const { forms } = await client.getForms({ siteId });
```

Note: While forms can be rendered from cache during CMS outages, form submissions will fail since they require the live API.

### Rendering Forms

Forms provide schema and settings for building form UI:

```tsx
function DynamicForm({ form }) {
  const handleSubmit = async (data) => {
    await fetch('/api/forms/submit', {
      method: 'POST',
      body: JSON.stringify({ formId: form.id, data }),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      {form.schema.fields.map((field) => (
        <FormField key={field.name} field={field} />
      ))}
      <button type="submit">Submit</button>
    </form>
  );
}
```

## Analytics

Track page views, CTA clicks, form submissions, and custom events.

### Add Analytics to Layout

```tsx
import { AnalyticsBootstrap } from '@riverbankcms/sdk/analytics';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}

        {/* Auto-tracks page views on navigation */}
        <AnalyticsBootstrap
          siteId="your-site-id"
          siteSlug="your-site-slug"
          endpoint="/api/analytics/collect"
        />
      </body>
    </html>
  );
}
```

### Track Events in Components

```tsx
'use client';

import { useAnalytics } from '@riverbankcms/sdk/analytics';

export function MyComponent() {
  const analytics = useAnalytics({
    siteId: 'your-site-id',
    siteSlug: 'your-site-slug',
  });

  const handleClick = () => {
    analytics.trackCtaClick({ buttonLabel: 'Sign Up' });
  };

  const handleSubmit = () => {
    analytics.trackFormSubmit({ formName: 'contact-form' });
  };

  const handleCustomEvent = () => {
    analytics.trackEvent({
      eventType: 'video_play',
      metadata: { videoId: '123', duration: 120 },
    });
  };

  return (
    <>
      <button onClick={handleClick}>Sign Up</button>
      <form onSubmit={handleSubmit}>...</form>
    </>
  );
}
```

## Theming

Style Builder blocks to match your brand using the Theme Bridge. This provides CSS variables and optional component styles without requiring the full CMS theme system.

### Quick Start

Import the SDK base stylesheet, then wrap your app with `ThemeBridgeProvider` and define your color tokens:

```tsx
import '@riverbankcms/sdk/styles.css';
import { ThemeBridgeProvider } from '@riverbankcms/sdk/theme-bridge';

export default function RootLayout({ children }) {
  return (
    <ThemeBridgeProvider
      config={{
        tokens: {
          primary: '#6d28d9',
          secondary: '#4c1d95',
          background: '#ffffff',
          text: '#1e293b',
        },
      }}
    >
      {children}
    </ThemeBridgeProvider>
  );
}
```

### Token System

Define color tokens as key-value pairs. Keys become runtime CSS variables such as `--tb-primary`:

```tsx
<ThemeBridgeProvider
  config={{
    tokens: {
      // Brand colors
      primary: '#6d28d9',
      secondary: '#4c1d95',

      // Backgrounds
      background: '#ffffff',
      surface: '#f8fafc',

      // Text. foreground/mutedForeground are preferred;
      // text/mutedText remain accepted aliases.
      foreground: '#1e293b',
      mutedForeground: '#64748b',

      // UI
      border: '#e2e8f0',
      primaryForeground: '#ffffff',

      // Status
      success: '#22c55e',
      warning: '#f59e0b',
      destructive: '#ef4444',
      info: '#0ea5e9',
    },
  }}
>
```

Token values can be:
- **Hex colors**: `'#6d28d9'` (converted to RGB for Tailwind alpha support)
- **CSS variable refs**: `'var(--brand-purple)'` (passed through)
- **RGB values**: `'109 40 217'` (used directly)

Canonical tokens win when aliases are also present. Accepted aliases are `text -> foreground`, `mutedText -> mutedForeground`, `white -> primaryForeground`, and `danger -> destructive`.

### Design Presets

Control typography, spacing, corners, and shadows:

```tsx
<ThemeBridgeProvider
  config={{
    tokens: { /* ... */ },

    // Typography
    typography: {
      headingFamily: '"Inter", sans-serif',
      bodyFamily: '"Inter", sans-serif',
      headingWeight: 700,
      bodyWeight: 400,
    },

    // Spacing density
    spacing: 'standard',  // 'comfortable' | 'standard' | 'dense'

    // Corner radius
    corners: 'rounded',   // 'square' | 'soft' | 'rounded' | 'pill'

    // Shadow intensity
    shadows: 'medium',    // 'none' | 'low' | 'medium' | 'high'
  }}
>
```

### Component CSS (Opt-in)

By default, the provider emits CSS variables plus structural runtime CSS used by system blocks: status classes, step/progress classes, and block-specific variables such as appointment booking `--ab-*` vars. Generated visual skins for buttons, cards, and inputs remain opt-in:

```tsx
<ThemeBridgeProvider
  config={{
    tokens: {
      primary: '#6d28d9',
      secondary: '#4c1d95',
      primaryForeground: '#ffffff',
      surface: '#f8fafc',
      text: '#1e293b',
      border: '#e2e8f0',
    },
    corners: 'rounded',
    shadows: 'medium',
    components: {
      buttons: true,  // Generates .button-primary, .button-secondary, etc.
      cards: true,    // Generates .card-default, .card-elevated, etc.
      inputs: true,   // Generates .form-input, .form-label, etc.
    },
  }}
>
```

### Custom Component Variants

Specify which variants to generate:

```tsx
components: {
  buttons: {
    variants: ['primary', 'secondary'],  // Only these variants
  },
  cards: {
    variants: ['default', 'elevated'],
  },
}
```

### CSS Overrides

Add custom CSS rules scoped to theme components:

```tsx
<ThemeBridgeProvider
  config={{
    tokens: { /* ... */ },
    components: { buttons: true },
    overrides: {
      '.button-primary': 'border-radius: 9999px; font-weight: 700;',
      '.button-primary:hover': 'transform: translateY(-2px);',
    },
  }}
>
```

### Pass-Through to Design Systems

Reference existing CSS variables from your design system:

```tsx
<ThemeBridgeProvider
  config={{
    tokens: {
      primary: 'var(--brand-purple)',
      secondary: 'var(--brand-navy)',
      background: 'var(--ds-bg)',
      foreground: 'var(--ds-text)',
    },
  }}
>
```

### Using Generated CSS Directly

For advanced use cases, generate CSS without the provider:

```tsx
import { generateThemeBridgeCss } from '@riverbankcms/sdk/theme-bridge';

const { css, cssVars } = generateThemeBridgeCss({
  tokens: {
    primary: '#6d28d9',
    background: '#ffffff',
  },
  components: { buttons: true },
});

// css: Full CSS string for injection
// cssVars: Object of CSS variable name-value pairs
```

When using `generateThemeBridgeCss` directly, render the affected blocks inside an element with `data-theme-scope="sdk"` so the generated status, progress, component, and block-var selectors apply. `ThemeBridgeProvider` adds that wrapper for you.

### Available Exports

```tsx
import {
  // Provider component
  ThemeBridgeProvider,
  useThemeBridgeCss,

  // CSS generator
  generateThemeBridgeCss,

  // Types
  type ThemeBridgeConfig,
  type ThemeBridgeTypography,
  type ThemeBridgeSpacing,
  type ThemeBridgeCorners,
  type ThemeBridgeShadows,
  type ThemeBridgeComponents,
  type ThemeBridgeOutput,
} from '@riverbankcms/sdk/theme-bridge';
```

## Block Field Options

Customize field options for specific blocks directly in your SDK configuration. This allows SDK sites to define site-specific choices for select fields in system blocks.

### Basic Configuration

Add `blockFieldOptions` to your `riverbank.config.ts`:

```typescript
import { defineConfig } from '@riverbankcms/sdk/config';

export default defineConfig({
  siteId: 'your-site-id',
  blockFieldOptions: {
    'block.embed': {
      layout: {
        options: [
          { value: 'showcase', label: 'Showcase Grid' },
          { value: 'list', label: 'Simple List' },
          { value: 'featured', label: 'Featured Hero' },
        ]
      }
    }
  }
});
```

### How It Works

1. **Block ID format**: Use `block.*` for system blocks or `custom.*` for custom blocks
2. **Field ID**: The field within the block whose options you want to override
3. **Options**: Array of `{ value, label }` objects that replace the default options

When a block field uses the `sdkSelect` widget (like the embed block's `layout` field), it will:
- Use SDK-provided options when available in `blockFieldOptions`
- Fall back to the field's default options if no SDK options are configured

### Types

```typescript
import type {
  FieldSelectOption,
  BlockFieldConfig,
  BlockFieldOptionsMap,
} from '@riverbankcms/sdk/config';

// A single select option
type FieldSelectOption = {
  value: string;  // Value stored when selected
  label: string;  // Display text in dropdown
};

// Configuration for a field
type BlockFieldConfig = {
  options?: FieldSelectOption[];  // Override select options
};

// Map of block IDs to field configurations
type BlockFieldOptionsMap = Record<string, Record<string, BlockFieldConfig>>;
```

### Current Use Cases

**Embed Block Layout**: The `block.embed` block uses `sdkSelect` for its `layout` field, allowing SDK sites to define custom layout options that match their site-specific renderers:

```typescript
export default defineConfig({
  siteId: 'your-site-id',
  blockFieldOptions: {
    'block.embed': {
      layout: {
        options: [
          { value: 'blog-grid', label: 'Blog Grid' },
          { value: 'team-cards', label: 'Team Cards' },
          { value: 'portfolio', label: 'Portfolio Gallery' },
        ]
      }
    }
  }
});
```

Then in your block override, handle each layout:

```tsx
function EmbedRenderer({ content, data }) {
  switch (content.layout) {
    case 'blog-grid':
      return <BlogGrid entries={data.entries} />;
    case 'team-cards':
      return <TeamCards entries={data.entries} />;
    case 'portfolio':
      return <PortfolioGallery entries={data.entries} />;
    default:
      return <DefaultList entries={data.entries} />;
  }
}
```

### Validation Rules

- Block IDs must match pattern: `block.*` or `custom.*` (lowercase letters, numbers, hyphens)
- Field IDs must be non-empty strings
- Each option must have both `value` and `label` (non-empty strings)
- At least one option is required when `options` is specified

## Block Field Extensions

Add custom fields to built-in block types without modifying the core CMS. This is different from `blockFieldOptions` (which overrides options for existing fields) – `blockFieldExtensions` lets you add entirely new fields.

### Basic Configuration

Add `blockFieldExtensions` to your `riverbank.config.ts`:

```typescript
import { defineConfig } from '@riverbankcms/sdk/config';

export default defineConfig({
  siteId: 'your-site-id',
  blockFieldExtensions: {
    'block.body-text': {
      fields: [
        {
          id: 'layout',
          type: 'select',
          label: 'Layout',
          defaultValue: 'default',
          required: false,
          multiple: false,
          options: [
            { value: 'default', label: 'Default' },
            { value: 'wide', label: 'Wide' },
            { value: 'narrow', label: 'Narrow' },
            { value: 'pullquote', label: 'Pull Quote' },
          ],
        },
      ],
    },
    'block.hero': {
      fields: [
        {
          id: 'videoBackground',
          type: 'media',
          label: 'Video Background',
          description: 'Optional video to play behind the hero',
          mediaKinds: ['video'],
          required: false,
        },
        {
          id: 'overlayOpacity',
          type: 'number',
          label: 'Overlay Opacity',
          description: 'Darkness of the overlay (0-100)',
          defaultValue: 50,
          required: false,
        },
      ],
    },
  },
});
```

### How It Works

1. **Extended fields appear at the end** of the block's editing form in the CMS
2. **Field values are stored** alongside normal block content
3. **Values are accessible** in your `blockOverrides` via the `content` prop
4. **All field types supported**: text, select, media, repeater, group, etc.

### Accessing Extended Fields in blockOverrides

```tsx
import { loadPage, Page } from '@riverbankcms/sdk';

export default async function CMSPage({ params }) {
  const pageData = await loadPage({ client, siteId, path: '/' });

  return (
    <Page
      {...pageData}
      blockOverrides={{
        bodyText: ({ content }) => {
          // Extended field values are available on content
          const layout = content.layout ?? 'default';

          const layoutClass = {
            default: 'max-w-prose mx-auto',
            wide: 'max-w-4xl mx-auto',
            narrow: 'max-w-md mx-auto',
            pullquote: 'max-w-lg mx-auto text-xl italic border-l-4 pl-6',
          }[layout];

          return (
            <div className={layoutClass}>
              <RichText content={content.body} />
            </div>
          );
        },
        hero: ({ content }) => {
          const { videoBackground, overlayOpacity = 50 } = content;

          return (
            <div className="relative">
              {videoBackground && (
                <video
                  src={videoBackground.url}
                  className="absolute inset-0 w-full h-full object-cover"
                  autoPlay
                  muted
                  loop
                />
              )}
              <div
                className="absolute inset-0 bg-black"
                style={{ opacity: overlayOpacity / 100 }}
              />
              <div className="relative z-10">
                <h1>{content.headline}</h1>
              </div>
            </div>
          );
        },
      }}
    />
  );
}
```

### Types

```typescript
import type {
  BlockFieldExtension,
  BlockFieldExtensionsMap,
  FieldDefinition,
} from '@riverbankcms/sdk/config';

// Configuration for extending a single block
type BlockFieldExtension = {
  fields: FieldDefinition[];  // Same field format as block manifests
};

// Map of block IDs to their extensions
type BlockFieldExtensionsMap = Record<string, BlockFieldExtension>;
```

### Validation Rules

- **Block IDs must be system blocks**: Only `block.*` format (e.g., `block.body-text`, `block.hero`). Custom blocks (`custom.*`) should define their fields directly in `customBlocks`.
- **Field IDs must be unique**: Extended field IDs cannot conflict with existing fields in the block. The CLI will error if you try to add a field with an ID that already exists.
- **Required fields need defaultValue**: If `required: true`, you must provide a `defaultValue`. This ensures existing blocks (created before the extension) can still be edited.
- **At least one field required**: Each block extension must have at least one field.

### Use Cases

**Layout variations for text blocks:**
```typescript
'block.body-text': {
  fields: [
    {
      id: 'layout',
      type: 'select',
      label: 'Layout',
      defaultValue: 'default',
      required: false,
      multiple: false,
      options: [
        { value: 'default', label: 'Default' },
        { value: 'wide', label: 'Wide' },
        { value: 'sidebar', label: 'With Sidebar' },
      ],
    },
  ],
}
```

**Custom styling options:**
```typescript
'block.hero': {
  fields: [
    {
      id: 'textAlignment',
      type: 'select',
      label: 'Text Alignment',
      defaultValue: 'center',
      required: false,
      multiple: false,
      options: [
        { value: 'left', label: 'Left' },
        { value: 'center', label: 'Center' },
        { value: 'right', label: 'Right' },
      ],
    },
    {
      id: 'showBreadcrumbs',
      type: 'boolean',
      label: 'Show Breadcrumbs',
      defaultValue: false,
      required: false,
    },
  ],
}
```

**Adding metadata fields:**
```typescript
'block.blog-listing': {
  fields: [
    {
      id: 'analyticsId',
      type: 'text',
      label: 'Analytics ID',
      description: 'Track this specific listing in analytics',
      required: false,
      multiline: false,
    },
  ],
}
```

### Comparison: blockFieldOptions vs blockFieldExtensions

| Feature | `blockFieldOptions` | `blockFieldExtensions` |
|---------|---------------------|------------------------|
| Purpose | Override options for existing select fields | Add new fields to blocks |
| Supports | Select field options only | All field types |
| Use case | Customize dropdown choices | Add new data fields |
| Block types | System and custom blocks | System blocks only |

Use **both together** for maximum customization:

```typescript
export default defineConfig({
  siteId: 'your-site-id',

  // Override options for existing fields
  blockFieldOptions: {
    'block.embed': {
      layout: {
        options: [
          { value: 'blog-grid', label: 'Blog Grid' },
          { value: 'team-cards', label: 'Team Cards' },
        ],
      },
    },
  },

  // Add new fields to blocks
  blockFieldExtensions: {
    'block.body-text': {
      fields: [
        {
          id: 'layout',
          type: 'select',
          label: 'Layout',
          defaultValue: 'default',
          required: false,
          multiple: false,
          options: [
            { value: 'default', label: 'Default' },
            { value: 'wide', label: 'Wide' },
          ],
        },
      ],
    },
  },
});
```

## Custom Blocks

Define site-specific blocks directly in your SDK configuration. Custom blocks appear in the CMS block picker alongside system blocks, are edited using CMS-generated forms, and are rendered by your own React components.

### Quick Start

1. **Define the block schema** in your `riverbank.config.ts`:

```typescript
import { defineConfig } from '@riverbankcms/sdk/config';

export default defineConfig({
  siteId: 'your-site-id',
  customBlocks: [
    {
      id: 'custom.team-member',
      title: 'Team Member',
      titleSource: 'name',
      description: 'Display a team member with photo and bio',
      category: 'content',
      icon: 'User',
      tags: ['team', 'about'],
      fields: [
        { id: 'name', type: 'text', label: 'Name', required: true, multiline: false },
        { id: 'role', type: 'text', label: 'Role', required: false, multiline: false },
        { id: 'photo', type: 'media', label: 'Photo', required: false, mediaKinds: ['image'] },
        { id: 'bio', type: 'richText', label: 'Bio', required: false },
      ],
    },
  ],
});
```

2. **Create your component** to render the block:

```tsx
// components/blocks/TeamMember.tsx
import type { BlockOverrideProps } from '@riverbankcms/sdk/rendering';

type TeamMemberContent = {
  name: string;
  role?: string;
  photo?: { url: string; alt?: string };
  bio?: string;
};

export function TeamMember({ content }: BlockOverrideProps<TeamMemberContent>) {
  return (
    <div className="flex items-start gap-6 p-6">
      {content.photo && (
        <img
          src={content.photo.url}
          alt={content.photo.alt || content.name}
          className="h-24 w-24 rounded-full object-cover"
        />
      )}
      <div>
        <h3 className="text-xl font-bold">{content.name}</h3>
        {content.role && <p className="text-gray-600">{content.role}</p>}
        {content.bio && (
          <div className="mt-2 prose" dangerouslySetInnerHTML={{ __html: content.bio }} />
        )}
      </div>
    </div>
  );
}
```

3. **Register the component** with `blockOverrides`:

```tsx
// app/[...slug]/page.tsx
import { loadPage, Page } from '@riverbankcms/sdk';
import { TeamMember } from '@/components/blocks/TeamMember';

export default async function CMSPage({ params }) {
  const pageData = await loadPage({
    client,
    siteId: process.env.SITE_ID!,
    path: `/${params.slug?.join('/') || ''}`,
  });

  return (
    <Page
      {...pageData}
      blockOverrides={{
        'custom.team-member': TeamMember,
      }}
    />
  );
}
```

### Block Definition Schema

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `id` | `custom.${string}` | Yes | Unique block ID, must start with `custom.` |
| `title` | `string` | Yes | Display name in block picker |
| `titleSource` | `string` | No | Field ID to use as block title in lists |
| `description` | `string` | No | Description shown in block picker |
| `category` | `BlockCategory` | Yes | One of: `marketing`, `content`, `blog`, `media`, `layout`, `interactive` |
| `icon` | `string` | No | Lucide icon name (e.g., `User`, `CreditCard`) |
| `tags` | `string[]` | No | Search tags for discoverability |
| `fields` | `FieldDefinition[]` | Yes | Field definitions for the block |

### Supported Field Types

All field types from the CMS are supported:

**Basic Types:**
- `text` - Single or multiline text
- `richText` - Rich text editor with formatting
- `number` - Numeric input
- `boolean` - Toggle/checkbox

**Media & Links:**
- `media` - Image, video, or file upload
- `url` - URL input with validation
- `link` - Internal or external link picker (supports page/entry identifiers)

**Selection:**
- `select` - Dropdown selection
- `reference` - Reference to other content entries

**Date/Time:**
- `date` - Date picker
- `time` - Time picker
- `datetime` - Combined date and time

**Complex Types:**
- `group` - Group related fields together
- `repeater` - Repeatable list of items
- `modal` - Fields in a modal dialog
- `tabGroup` - Tabbed field groups

### Examples

**Pricing Card:**

```typescript
{
  id: 'custom.pricing-card',
  title: 'Pricing Card',
  titleSource: 'planName',
  category: 'marketing',
  icon: 'CreditCard',
  fields: [
    { id: 'planName', type: 'text', label: 'Plan Name', required: true, multiline: false },
    { id: 'price', type: 'text', label: 'Price', required: false, multiline: false, description: 'e.g., "$29/mo"' },
    { id: 'featured', type: 'boolean', label: 'Featured Plan', required: false },
    {
      id: 'features',
      type: 'repeater',
      label: 'Features',
      required: false,
      itemLabel: 'Feature',
      maxItems: 10,
      fields: [
        { id: 'text', type: 'text', label: 'Feature', required: true, multiline: false },
        { id: 'included', type: 'boolean', label: 'Included', required: false },
      ],
    },
    { id: 'ctaLabel', type: 'text', label: 'Button Label', required: false, multiline: false },
    { id: 'ctaLink', type: 'link', label: 'Button Link', required: false },
  ],
}
```

**Testimonial:**

```typescript
{
  id: 'custom.testimonial',
  title: 'Testimonial',
  titleSource: 'authorName',
  category: 'content',
  icon: 'Quote',
  fields: [
    { id: 'quote', type: 'richText', label: 'Quote', required: true },
    { id: 'authorName', type: 'text', label: 'Author Name', required: true, multiline: false },
    { id: 'authorTitle', type: 'text', label: 'Author Title', required: false, multiline: false },
    { id: 'authorPhoto', type: 'media', label: 'Author Photo', required: false, mediaKinds: ['image'] },
    { id: 'companyLogo', type: 'media', label: 'Company Logo', required: false, mediaKinds: ['image'] },
    { id: 'rating', type: 'number', label: 'Rating (1-5)', required: false },
  ],
}
```

### Validation Rules

- Maximum 20 custom blocks per site
- Block IDs must be unique within the site
- Block IDs must start with `custom.` followed by lowercase letters, numbers, or hyphens
- `titleSource` must reference a valid field ID if provided
- At least one field is required per block
- Maximum 5 data loaders per block

### Best Practices

1. **Use descriptive IDs**: `custom.team-member` is better than `custom.tm`
2. **Set `titleSource`**: Helps editors identify blocks in the page structure
3. **Add descriptions**: Help editors understand when to use each block
4. **Group related fields**: Use `group` for related fields that should appear together
5. **Validate required fields**: Mark essential fields as `required: true`
6. **Provide defaults**: Use `defaultValue` for optional fields with sensible defaults

## Custom Block Data Loaders

Custom blocks can fetch data automatically using data loaders. There are two approaches:

1. **Config-based loaders** - Declarative loaders defined in `riverbank.config.ts` for whitelisted CMS endpoints
2. **Code-based loaders** - Functions passed to `loadPage()` for external APIs

Both loader types run server-side during `loadPage()`. Data is passed to your component via the `data` prop.

### Config-Based Loaders (CMS Endpoints)

Define loaders declaratively in your block definition. These are restricted to whitelisted CMS endpoints for security.

**Supported Endpoints:**

| Endpoint | Description |
|----------|-------------|
| `listPublishedEntries` | Fetch published content entries |
| `getPublishedEntryPreview` | Fetch a single entry by slug |
| `listPublicEvents` | Fetch events |
| `getPublicFormById` | Fetch form configuration |
| `getPublicBookingServices` | Fetch booking services |

**Parameter Bindings:**

Loader params can be static values or dynamic bindings:

- `{ $bind: { from: 'content.fieldName' } }` - Bind to a block field value
- `{ $bind: { from: 'content.fieldName', fallback: '10' } }` - With fallback
- `{ $bind: { from: 'content.fieldName', transforms: [{ id: 'value.jsonArray' }] } }` - With transforms (useful when binding arrays/objects into string params)
- `{ $bind: { from: '$root.siteId' } }` - Bind to site ID from page context
- `{ $bind: { from: '$root.pageId' } }` - Bind to page ID
- `{ $bind: { from: '$root.previewStage' } }` - 'published' or 'preview'

**Conditional Execution (`when`):**

Use `when` to conditionally run a loader. `when` accepts only:
- a static boolean
- a binding expression

The loader runs only when the resolved `when` value is exactly `true`.

```typescript
dataLoaders: {
  posts: {
    endpoint: 'listPublishedEntries',
    params: {
      siteId: { $bind: { from: '$root.siteId' } },
      type: 'blog-post',
    },
    when: { $bind: { from: 'content.showPosts', fallback: true } },
  },
}
```

**Example: Blog Listing Block**

```typescript
// riverbank.config.ts
import { defineConfig } from '@riverbankcms/sdk/config';

export default defineConfig({
  siteId: 'your-site-id',
  customBlocks: [
    {
      id: 'custom.featured-posts',
      title: 'Featured Posts',
      category: 'blog',
      icon: 'FileText',
      fields: [
        { id: 'limit', type: 'number', label: 'Number of posts', required: false },
        { id: 'category', type: 'text', label: 'Category filter', required: false, multiline: false },
      ],
      dataLoaders: {
        posts: {
          endpoint: 'listPublishedEntries',
          params: {
            siteId: { $bind: { from: '$root.siteId' } },
            type: 'blog-post',
            limit: { $bind: { from: 'content.limit', fallback: '10' } },
          },
        },
      },
    },
  ],
});
```

**Example: Manual Selection (Repeater + entryPicker + entryIds)**

When authors need to manually curate a list, prefer a repeater of `entryPicker` fields over a plain text identifier field.
Then pass the selected IDs/identifiers to `listPublishedEntries` in `mode: "manual"` to avoid fetching the full list.

Note: storing `valueFrom: "identifier"` is convenient, but identifiers can change; use UUIDs (`valueFrom: "id"`) if you need immutable references.

```ts
// riverbank.config.ts
import { defineConfig } from '@riverbankcms/sdk/config';

export default defineConfig({
  siteId: 'your-site-id',
  customBlocks: [
    {
      id: 'custom.featured-projects',
      title: 'Featured Projects',
      category: 'content',
      fields: [
        {
          id: 'mode',
          type: 'select',
          label: 'Mode',
          required: false,
          options: [
            { value: 'auto', label: 'Auto' },
            { value: 'manual', label: 'Manual' },
          ],
          defaultValue: 'auto',
        },
        {
          id: 'entries',
          type: 'repeater',
          label: 'Projects',
          schema: {
            fields: [
              {
                id: 'entryId',
                type: 'entryPicker',
                label: 'Project',
                fixedContentType: 'project',
                valueFrom: 'identifier',
              },
            ],
          },
        },
      ],
      dataLoaders: {
        projects: {
          endpoint: 'listPublishedEntries',
          params: {
            siteId: { $bind: { from: '$root.siteId' } },
            type: 'project',
            mode: { $bind: { from: 'content.mode', fallback: 'auto' } },
            entryIds: {
              $bind: {
                from: 'content.entries',
                transforms: [{ id: 'value.jsonArray' }],
              },
            },
          },
        },
      },
    },
  ],
});
```

```tsx
// components/blocks/FeaturedPosts.tsx
import type { BlockOverrideProps } from '@riverbankcms/sdk/rendering';

type FeaturedPostsContent = {
  limit?: number;
  category?: string;
};

type FeaturedPostsData = {
  posts: { entries: Array<{ id: string; title: string; slug: string }> };
};

export function FeaturedPosts({
  content,
  data,
}: BlockOverrideProps<FeaturedPostsContent, FeaturedPostsData>) {
  const posts = data?.posts?.entries ?? [];

  return (
    <div className="grid gap-4">
      {posts.map((post) => (
        <article key={post.id}>
          <a href={`/blog/${post.slug}`}>
            <h3>{post.title}</h3>
          </a>
        </article>
      ))}
    </div>
  );
}
```

### Code-Based Loaders (External APIs)

For data from external APIs, pass loader functions to `loadPage()`. This gives you full control over the fetch logic.

**Example: E-commerce Product Block**

```typescript
// app/[...slug]/page.tsx
import { loadPage, Page } from '@riverbankcms/sdk';
import { FeaturedProducts } from '@/components/blocks/FeaturedProducts';

export default async function CMSPage({ params }) {
  const pageData = await loadPage({
    client,
    siteId: process.env.SITE_ID!,
    path: `/${params.slug?.join('/') || ''}`,
    dataLoaderOverrides: {
      'custom.featured-products': {
        products: async (ctx) => {
          // Access block content for parameters
          const categoryId = ctx.content.categoryId as string;
          const limit = (ctx.content.limit as number) ?? 10;

          const res = await fetch(
            `https://api.shop.com/products?category=${categoryId}&limit=${limit}`,
            { headers: { 'Authorization': `Bearer ${process.env.SHOP_API_KEY}` } }
          );

          return res.json();
        },
      },
    },
  });

  return (
    <Page
      {...pageData}
      blockOverrides={{
        'custom.featured-products': FeaturedProducts,
      }}
    />
  );
}
```

```tsx
// components/blocks/FeaturedProducts.tsx
import type { BlockOverrideProps } from '@riverbankcms/sdk/rendering';

type ProductsContent = {
  categoryId: string;
  limit?: number;
};

type ProductsData = {
  products: Array<{ id: string; name: string; price: number; image: string }>;
};

export function FeaturedProducts({
  content,
  data,
}: BlockOverrideProps<ProductsContent, ProductsData>) {
  const products = data?.products ?? [];

  return (
    <div className="grid grid-cols-3 gap-6">
      {products.map((product) => (
        <div key={product.id} className="border rounded p-4">
          <img src={product.image} alt={product.name} />
          <h3>{product.name}</h3>
          <p>${product.price}</p>
        </div>
      ))}
    </div>
  );
}
```

### DataLoaderContext

Code-based loaders receive context about the block and page:

```typescript
interface DataLoaderContext {
  /** Site ID from page context */
  siteId: string;

  /** Page ID from page context */
  pageId: string;

  /** Unique block instance ID */
  blockId: string;

  /** Block kind (e.g., 'custom.featured-products') */
  blockKind: string;

  /** The block's CMS content (field values) */
  content: Record<string, unknown>;

  /** Whether fetching preview/draft content */
  previewStage: 'published' | 'preview';
}
```

### Combining Config and Code Loaders

You can use both loader types together. Config loaders run first, then code loaders. On key conflicts, code loaders take precedence.

```typescript
// riverbank.config.ts - Config loader for CMS data
customBlocks: [{
  id: 'custom.product-showcase',
  dataLoaders: {
    relatedPosts: {
      endpoint: 'listPublishedEntries',
      params: {
        siteId: { $bind: { from: '$root.siteId' } },
        type: 'blog-post',
        limit: '3',
      },
    },
  },
}]

// page.tsx - Code loader for external API
const pageData = await loadPage({
  client,
  siteId,
  path: '/',
  dataLoaderOverrides: {
    'custom.product-showcase': {
      products: async (ctx) => fetchProductsFromShopify(ctx.content.collectionId),
    },
  },
});

// Component receives both: data.relatedPosts (CMS) and data.products (Shopify)
```

### Error Handling

Data loaders are best-effort. If a loader fails:

- The error is logged to console
- The loader key is undefined in the `data` prop
- Other loaders continue executing
- Your component should handle missing data gracefully

```tsx
function MyBlock({ data }: BlockOverrideProps<Content, Data>) {
  // Handle missing data
  const products = data?.products ?? [];

  if (products.length === 0) {
    return <p>No products available</p>;
  }

  return <ProductGrid products={products} />;
}
```

### Data Exports

Import data utilities from `@riverbankcms/sdk/data`:

```typescript
import {
  // Core prefetch function
  prefetchBlockData,

  // Code loader execution
  executeCodeLoaders,
  mergeLoaderResults,

  // Types
  type DataLoaderContext,
  type DataLoaderFn,
  type BlockLoaderMap,
  type DataLoaderOverrides,
  type PrefetchContext,
  type ResolvedBlockData,
  type SdkPrefetchOptions,
} from '@riverbankcms/sdk/data';
```

## Content Configuration (SDK Content Scaffolding)

Define content type schema, local content scaffolding, navigation menus, and site settings in code. `push-config` syncs schema/config surfaces; mutable documents such as pages, entries, and navigation are synced with explicit `riverbankcms push` scopes.

### Quick Start

Create a `content.config.ts` file alongside your `riverbank.config.ts`:

```typescript
// content.config.ts
import { defineContentConfig } from '@riverbankcms/sdk/config';

export const contentConfig = defineContentConfig({
  // Content type definitions
  contentTypes: [
    {
      key: 'testimonial',
      name: 'Testimonial',
      hasPages: false,
      fields: [
        { id: 'quote', type: 'richText', label: 'Quote', required: true },
        { id: 'author', type: 'text', label: 'Author Name', required: true, multiline: false },
      ],
    },
  ],

  // Page definitions
  pages: [
    {
      identifier: 'home',
      path: '/',
      title: 'Home',
      status: 'published',
    },
  ],

  // Content entries
  entries: [
    {
      identifier: 'testimonial-1',
      contentType: 'testimonial',
      status: 'published',
      data: {
        quote: '<p>Great product!</p>',
        author: 'Jane Doe',
      },
    },
  ],

  // Navigation menus
  navigationMenus: [
    {
      identifier: 'main',
      name: 'Main Navigation',
      isPrimary: true,
      items: [
        { label: 'Home', link: { kind: 'page', identifier: 'home' } },
        { label: 'Get Started', link: { kind: 'url', href: '/signup' }, isCta: true },
      ],
    },
  ],

  // Site settings
  settings: {
    homepagePath: '/',
    siteTitle: 'My Site',
    siteDescription: 'Welcome to my site',
  },
});
```

Then import it in your main config:

```typescript
// riverbank.config.ts
import { defineConfig } from '@riverbankcms/sdk/config';
import { contentConfig } from './content.config';

export default defineConfig({
  siteId: 'your-site-id',
  theme: { /* ... */ },
  content: contentConfig,
});
```

### Content Types

Define reusable content structures:

```typescript
contentTypes: [
  {
    key: 'blog-post',           // Unique identifier
    name: 'Blog Post',          // Display name
    description: 'Blog articles',
    hasPages: true,             // Whether entries are routable
    routePattern: '/blog/{slug}', // URL pattern (required if hasPages)
    fields: [
      { id: 'body', type: 'richText', label: 'Content', required: true },
      { id: 'author', type: 'text', label: 'Author', required: false, multiline: false },
      { id: 'image', type: 'media', label: 'Featured Image', mediaKinds: ['image'] },
    ],
    titleField: 'title',        // Field to use as entry title
  },
]
```

### Singleton Content Types

Singleton content types can only have one entry. Use them for unique content like homepage settings, site-wide configuration, or structured content that shouldn't be duplicated:

```typescript
contentTypes: [
  {
    key: 'homepage',
    name: 'Homepage',
    isSingleton: true,          // Only one entry allowed
    hasPages: true,
    routePattern: '/',          // Fixed path (no {slug} needed)
    fields: [
      { id: 'heroTitle', type: 'text', label: 'Hero Title', required: true, multiline: false },
      { id: 'heroSubtitle', type: 'text', label: 'Hero Subtitle', required: false, multiline: false },
      { id: 'heroImage', type: 'media', label: 'Hero Image', mediaKinds: ['image'] },
    ],
    titleField: 'heroTitle',
  },
  {
    key: 'site-settings',
    name: 'Site Settings',
    isSingleton: true,
    hasPages: false,            // Not routable
    fields: [
      { id: 'contactEmail', type: 'text', label: 'Contact Email', required: false, multiline: false },
      { id: 'footerText', type: 'richText', label: 'Footer Text', required: false },
    ],
    titleField: 'contactEmail',
  },
]
```

**Singleton behavior:**

- When the content type is created, an entry is automatically created
- Attempting to create additional entries returns a 409 Conflict error
- The entry can be deleted and will reappear when recreated
- Routable singletons use a fixed path instead of `{slug}` pattern

### Pages

Define pages with optional initial blocks:

```typescript
pages: [
  {
    identifier: 'about',        // Required, unique per site
    path: '/about',             // URL path
    title: 'About Us',
    purpose: 'content',         // 'content' | 'landing' | etc.
    status: 'draft',            // 'draft' | 'published'
    metaTitle: 'About Us | My Site',
    metaDescription: 'Learn more about our company',
    blocks: [
      {
        kind: 'block.hero',
        content: { headline: 'About Us', subheadline: 'Our Story' },
        orderIndex: 0,
      },
    ],
  },
]
```

### Content Entries

Create content entries for your content types:

```typescript
entries: [
  {
    identifier: 'john-smith',   // Required, unique per site
    contentType: 'team-member', // Content type key
    slug: 'john-smith',         // For routable entries (defaults to identifier)
    status: 'published',
    data: {
      bio: '<p>Our founder...</p>',
      role: 'CEO',
    },
    metaTitle: 'John Smith | Team',
    metaDescription: 'Meet John Smith, our CEO',
  },
]
```

### Navigation Menus

Define navigation structure with page/entry references:

```typescript
navigationMenus: [
  {
    identifier: 'main',
    name: 'Main Navigation',
    isPrimary: true,
    items: [
      // Link to a page by identifier
      { label: 'Home', link: { kind: 'page', identifier: 'home' } },

      // Link to a routable content entry
      { label: 'About John', link: { kind: 'entry', contentType: 'team-member', identifier: 'john-smith' } },

      // External link
      { label: 'GitHub', link: { kind: 'external', href: 'https://github.com' } },

      // CTA link (root-level links only, not dropdowns)
      { label: 'Get Started', link: { kind: 'url', href: '/signup' }, isCta: true },

      // Dropdown with children
      {
        label: 'Resources',
        link: { kind: 'dropdown' },
        children: [
          { label: 'Blog', link: { kind: 'page', identifier: 'blog' } },
          { label: 'Docs', link: { kind: 'external', href: 'https://docs.example.com' } },
        ],
      },
    ],
  },
]
```

`isCta` is optional and only valid on root-level link items. Dropdown items and nested child items cannot be CTAs.

### Site Settings

Configure site-wide settings:

```typescript
settings: {
  homepagePath: '/',           // Path to homepage
  siteTitle: 'My Site',        // Site title for metadata
  siteDescription: 'Welcome',  // Site description for metadata
}
```

### Sync Behavior

When you run `riverbank push`:

1. **Creates** new content if the identifier doesn't exist
2. **Updates** existing content if it hasn't been modified in the dashboard
3. **Skips** content that was edited in the dashboard (preserves manual changes)
4. **Never deletes** - removing from config doesn't delete from CMS

This "overwrite if unchanged" behavior ensures:
- SDK-defined content can be version controlled
- Dashboard edits are never lost
- Teams can safely iterate on config

### Sync Order

Content syncs in dependency order:

1. Content Types (no dependencies)
2. Site Settings (no dependencies)
3. Pages (needed for navigation references)
4. Content Entries (depend on content types)
5. Navigation Menus (reference pages and entries)

### Validation

The SDK validates your content config on push:

- Unique identifiers per entity type
- Valid content type references
- Valid field definitions
- Navigation references resolve to existing pages/entries
- Path uniqueness for pages

### Types

```typescript
import {
  // Helper function
  defineContentConfig,

  // Types
  type ContentConfig,
  type ContentTypeConfig,
  type PageConfig,
  type EntryConfig,
  type BlockConfig,
  type NavigationMenuConfig,
  type NavigationItemConfig,
  type SiteSettingsConfig,
} from '@riverbankcms/sdk/config';
```

## Site URLs (Preview & Live)

SDK sites can configure custom preview and live URLs that appear in the dashboard. This allows the CMS to link to your staging and production environments.

### Configuration

Add `previewUrl` and `liveUrl` to your `riverbank.config.ts`:

```typescript
import { defineConfig } from '@riverbankcms/sdk/config';

export default defineConfig({
  siteId: 'your-site-id',

  // Preview URL for staging/development environment
  previewUrl: 'https://staging.mysite.com',

  // Live URL for production environment
  liveUrl: 'https://mysite.com',

  // ... other config
});
```

### How It Works

When you push your SDK config with `riverbank push-config`, schema/config surfaces are synced:

1. URLs are validated and stored in the CMS
2. Dashboard "Preview" buttons redirect to your `previewUrl`
3. "View live site" links redirect to your `liveUrl`
4. If a URL isn't configured, the corresponding button/link is hidden

### URL Requirements

- **HTTPS required** for production URLs
- **HTTP allowed** for local development (localhost, *.localhost, *.local, *.test)
- **Trailing slashes** are automatically stripped

### Examples

**Full production setup:**
```typescript
export default defineConfig({
  siteId: 'your-site-id',
  previewUrl: 'https://staging.mysite.com',
  liveUrl: 'https://mysite.com',
});
```

**Development only:**
```typescript
export default defineConfig({
  siteId: 'your-site-id',
  previewUrl: 'http://localhost:3000',
  // liveUrl omitted - "View live site" button hidden in dashboard
});
```

**Preview only (no public site yet):**
```typescript
export default defineConfig({
  siteId: 'your-site-id',
  previewUrl: 'https://preview.mysite.com',
  // liveUrl omitted until site launches
});
```

### Behavior Summary

| Configuration | Preview Button | View Live Site |
|--------------|----------------|----------------|
| Both URLs set | Links to `previewUrl` | Links to `liveUrl` |
| Only `previewUrl` | Links to `previewUrl` | Hidden |
| Only `liveUrl` | Hidden | Links to `liveUrl` |
| Neither set | Hidden | Hidden |

### Notes

- SDK sites do **not** use the managed subdomain URLs (e.g., `yoursite.app.riverbankcms.com`)
- Managed (non-SDK) sites continue to use subdomain URLs automatically
- URLs are stored in `sdk_config` and synced when you push

## CLI

The SDK includes a CLI for syncing configuration and content between your local environment and the CMS.

### push-config

Push schema/config surfaces to your CMS dashboard, including custom blocks, block field options/extensions, content type definitions/templates, SDK-managed site settings, and footer blocks:

```bash
npx riverbankcms push-config --dashboard <url>
```

Pages, content entries, and navigation menus are content documents. Sync them with `riverbankcms push pages`, `riverbankcms push entries`, or `riverbankcms push navigation`.

**Options:**

| Option | Description |
|--------|-------------|
| `--api-key <key>` | Management API key (or set `RIVERBANK_*_MGMT_API_KEY`) |
| `--dashboard <url>` | Dashboard URL (or set `RIVERBANK_*_DASHBOARD_URL`) |
| `--config <path>` | Path to config file (default: `./riverbank.config.ts`) |

**Example:**

```bash
# Using environment variables (local)
export RIVERBANK_LOCAL_DASHBOARD_URL=https://www.riverbankcms.com
export RIVERBANK_LOCAL_MGMT_API_KEY=bld_mgmt_sk_...
npx riverbankcms push-config

# Or inline (remote)
npx riverbankcms push-config \
  --api-key bld_mgmt_sk_... \
  --dashboard https://www.riverbankcms.com
```

### pull

Download content from the CMS to local JSON files:

```bash
npx riverbankcms pull [scope] [type] [identifier]
```

**Scopes:**

| Scope | Description |
|-------|-------------|
| (none) / `all` | Pull all content types |
| `entries` | Pull all entries, a content type, or a single entry |
| `pages` | Pull all pages or a single page |
| `navigation` | Pull navigation menus |
| `settings` | Pull site settings |
| `forms` | Pull form definitions |

**Examples:**

```bash
# Pull all content
npx riverbankcms pull

# Pull only forms
npx riverbankcms pull forms

# Pull entries for a specific content type
npx riverbankcms pull entries blog-post

# Pull one entry
npx riverbankcms pull entries blog-post welcome

# Pull one page
npx riverbankcms pull pages home

# Pull from remote environment
npx riverbankcms pull --env=remote

# Sync media files between environments
npx riverbankcms pull --env=remote --sync-media --yes

# Overwrite target media on checksum mismatch (explicit / unsafe)
npx riverbankcms pull --env=remote --sync-media --overwrite-media --yes

# Download media referenced by identifiers
npx riverbankcms pull --local-media
```

**Output Structure:**

```
content/
├── entries/
│   └── {content-type}/
│       └── {identifier}.json
├── pages/
│   └── {identifier}.json
├── forms/
│   └── {slug}.json
├── navigation.json
├── settings.json
├── media/
│   ├── <identifier>.<ext>
│   └── .manifest.json
└── .meta/
    └── {entity}.json      # Timestamps for sync detection
```

Note: `content/media/.manifest.json` is a local cache (identifier → checksum/filename). It intentionally does not store env-specific `assetId`s.

### push

Push local content to the CMS with diff-based sync:

```bash
npx riverbankcms push [scope] [type] [identifier]
```

**Scopes:**

| Scope | Description |
|-------|-------------|
| (none) / `all` | Push all content types |
| `entries` | Push all entries, a content type, or a single entry |
| `pages` | Push all pages or a single page with blocks |
| `navigation` | Push navigation menus |
| `forms` | Push form definitions |

**Options:**

| Option | Description |
|--------|-------------|
| `--dry-run` | Show what would be pushed without making changes |
| `--yes` | Skip confirmation (required for `--env=remote`) |
| `--force` | Push even if remote is newer |
| `--force-update-asset` | Replace CMS media when identifiers conflict (local media sync only) |
| `--sync-media` | Sync media files from local to remote environment |
| `--overwrite-media` | When using `--sync-media`, overwrite target media on checksum mismatch |
| `--json-diff` | Output JSON diff (`summary` or `full`) |
| `--merge-remote` | Plan an entries-only three-way merge for `--env=remote` that preserves remote edits |

**Examples:**

```bash
# Preview changes (dry run)
npx riverbankcms push --dry-run

# Push all content
npx riverbankcms push

# Push only forms
npx riverbankcms push forms

# Push to production with confirmation bypass
npx riverbankcms push --env=remote --yes

# Preview a production-safe merge for entries
npx riverbankcms push entries blog-post --env=remote --merge-remote

# Apply that merge after reviewing the plan
npx riverbankcms push entries blog-post --env=remote --merge-remote --yes

# Get JSON diff for automation
npx riverbankcms push --dry-run --json-diff=summary
```

**Remote-aware entry merge:**

`--merge-remote` is a safer production entries workflow. It compares the last pulled entry base, your local files, and the current remote CMS, then reports a merge plan. Remote runs still default to dry-run; add `--yes` only after reviewing the plan. In v1 this is entries-only, `--env=remote` only, and cannot be combined with `--force`, `--delete-orphaned`, or `--auto-pull-stale`.

**Local Media (Identifiers):**

- Add `identifier` to media fields (slug only, no extension)
- Place files at `content/media/<identifier>.<ext>`
- Use `npx riverbankcms push` to upload referenced files (default-on)
- Use `--force-update-asset` to replace a CMS asset when checksums differ

**Site branding via `content/settings.json`:**

```json
{
  "homepageId": null,
  "seoDefaults": null,
  "logoIdentifier": "positive-play-primary-logo",
  "faviconIdentifier": "positive-play-primary-logo"
}
```

- Keep branding files in `content/media/<identifier>.<ext>`
- Run `npx riverbankcms push` to upload missing files and assign the site logo/favicon in one flow
- Run `npx riverbankcms pull settings` to round-trip the current identifier-based branding state
- If the current CMS logo/favicon asset has no media identifier yet, pull warns and omits that field instead of writing a destructive clear
- Dashboard branding edits and CLI pushes both write the same site settings columns, so pull before pushing if someone may have changed branding in the dashboard

**Media portability (important):**

- Content JSON is portable across environments; media references must be **identifier-only**.
- Do not commit or hand-write `assetId`, `storagePath`, `storageBucket`, or `src` into content JSON.

**Sync Behavior:**

- **Creates** new content if identifier/slug doesn't exist
- **Updates** existing content based on diff
- **Never deletes** - removing from local doesn't delete from CMS
- **Stale detection** - warns if remote changed since last pull (use `--force` to override)

## Next.js Catch-All Page Factory

The SDK provides a `createCatchAllPage()` factory that dramatically reduces boilerplate for Next.js catch-all routes. It handles page/entry routing, preview mode, metadata generation, automatic CMS island hydration, and error handling in ~10 lines.

### Basic Usage

```tsx
// src/app/[[...slug]]/page.tsx
import { createCatchAllPage } from '@riverbankcms/sdk/next';
import { getRiverbankClient } from '@/lib/builder-client';
import config from '@/riverbank.config';

const { Page, generateMetadata } = createCatchAllPage({
  getClient: getRiverbankClient,
  config,
  blockOverrides: { /* custom block components */ },
});

export default Page;
export { generateMetadata };
```

### Upstream content-load failures

`createCatchAllPage()` only calls `notFound()` for confirmed content misses, such as an API 404 for an absent page. Timeout, network, 5xx, auth, and circuit-open failures from the CMS are surfaced as `RiverbankContentLoadError` by default so monitors and crawlers do not mistake a transient upstream problem for missing content. `SdkContentLoadError` remains available as a compatibility alias for the same error class.

If you want to render a custom temporary-unavailable view, use `onContentLoadFailure`. Be aware that rendering a fallback from a Next.js page component may produce a normal page response; if you need a specific HTTP status such as 503, verify it in your host app and error-boundary setup.

```tsx
import { createCatchAllPage } from '@riverbankcms/sdk/next';

const { Page, generateMetadata } = createCatchAllPage({
  getClient: getRiverbankClient,
  config,
  getRequestContext: () => ({
    host: process.env.NEXT_PUBLIC_SITE_HOST,
  }),
  onContentLoadFailure: ({ failure }) => (
    <main>
      <h1>Temporarily unavailable</h1>
      <p>Please try again shortly.</p>
      <small>{failure.kind}</small>
    </main>
  ),
});

export default Page;
export { generateMetadata };
```

### Lazy Block Overrides

Eager `blockOverrides` remain supported and are a good fit for small server-safe components. For client-heavy or rarely used custom blocks, register a lazy override so `createCatchAllPage()` resolves only the override modules needed by the page or entry template being rendered:

```tsx
// src/app/[[...slug]]/page.tsx
import { createCatchAllPage } from '@riverbankcms/sdk/next';
import { defineLazyBlockOverride } from '@riverbankcms/sdk/rendering';
import { getRiverbankClient } from '@/lib/builder-client';
import config from '@/riverbank.config';

const { Page, generateMetadata } = createCatchAllPage({
  getClient: getRiverbankClient,
  config,
  blockOverrides: {
    'custom.quiz': defineLazyBlockOverride(async () =>
      import('@/components/blocks/QuizBlock').then((module) => module.QuizBlock)
    ),
  },
});

export default Page;
export { generateMetadata };
```

Keep the `import()` inside the lazy factory. Wrapping a component that was imported at the top of the route file is still eager from the bundler's point of view and will not slim the catch-all route graph. Lazy factories must be wrapped with `defineLazyBlockOverride()`; raw promise-returning functions are not treated as lazy overrides.

`createCatchAllPage()` filters and resolves lazy overrides per rendered page, so unused factories are not invoked. Next.js still builds the client-reference manifest from the static catch-all route graph, though, so a static `import('./HeavyClientBlock')` inside the route graph can still appear in internal build manifests even when that block is not on the current page. For client-heavy blocks, keep the server override wrapper small and put ALTCHA/forms/booking widgets behind block-local client boundaries, then verify the production build artifacts for the specific site.

For a fuller public-performance checklist covering broad client imports, bundle measurement, and decorative image/LCP pitfalls, see [`docs/usage-patterns.md#performance-optimization`](./docs/usage-patterns.md#performance-optimization).

This single call replaces ~160 lines of typical boilerplate that handles:
- Path resolution from URL slugs
- Content loading (pages and entries)
- Entry subroute routing (event occurrences at `/events/yoga-class/2025-01-15`, course runs at `/courses/yoga-101/spring-2026`, …)
- Preview mode detection
- Template rendering for entries
- SEO metadata generation
- 404 handling

### Preview Mode

Preview mode is detected via the `RIVERBANK_PREVIEW_MODE` environment variable:

```bash
# In your preview/staging environment
RIVERBANK_PREVIEW_MODE=true
```

When enabled, `loadContent()` fetches draft/unpublished content instead of published content.

### Entry Subroute Routing

For content types that declare `routing.subroutes` (events, courses, …), `createCatchAllPage` automatically handles "one-extra-segment" URLs under the parent entry. For example, with an event entry at `/events/yoga-class` and a course entry at `/courses/yoga-101`:

- `/events/yoga-class/2025-01-15` — date-based event occurrence (ISO date)
- `/events/yoga-class/abc123-def4-...` — UUID-based event occurrence
- `/courses/yoga-101/spring-2026` — slug-based course run

The page detects these patterns using local route matching (no extra API calls), fetches the parent entry, and calls the public resolve endpoint to fetch the subroute payload. The result is passed to blocks via `dataContext.entrySubrouteContext`, a discriminated union of `{ kind: 'event-occurrence'; occurrence } | { kind: 'course-run'; run }`. Blocks that need it (event registration, course registration) read the variant they care about.

Invalid subroute segments (e.g., `/events/yoga-class/invalid-date`) return a 404.

See [`docs/guides/entry-subroutes.md`](../../docs/guides/entry-subroutes.md) for the full data flow and instructions for adding a new subroute kind.

#### Lower-Level `resolvePageContent` Contract

If you use `resolvePageContent` directly from `@riverbankcms/sdk/routing`, the `loadContent` callback must return the explicit routing result helpers rather than `content | null`:

```ts
import { RiverbankApiError } from '@riverbankcms/sdk'
import {
  contentFound,
  contentLoadFailed,
  contentNotFound,
  resolvePageContent,
} from '@riverbankcms/sdk/routing'

await resolvePageContent({
  path,
  contentTypes,
  loadContent: async (contentPath) => {
    try {
      const content = await client.getPage({ siteId, path: contentPath })
      return contentFound(content)
    } catch (error) {
      if (error instanceof RiverbankApiError && error.status === 404) {
        return contentNotFound()
      }

      return contentLoadFailed({
        kind: 'unknown',
        stage: 'content_lookup',
        message: error instanceof Error ? error.message : String(error),
      })
    }
  },
  resolveEntrySubroute,
  isEntry,
  getEntryId,
  getEntryTypeKey,
})
```

This distinction is intentional: true absence remains a 404 candidate, while transient CMS/API failures can surface through the host application's error boundary instead of being cached or reported as missing content.

### Escape Hatches

For customization beyond the defaults, use these options:

#### `beforeRender` - Maintenance Mode, Access Control

Intercept before rendering to implement maintenance mode, authentication, etc. Return a React node to short-circuit rendering, or `null` to continue.

```tsx
const { Page, generateMetadata } = createCatchAllPage({
  getClient,
  config,
  beforeRender: async ({ content, path, preview, searchParams }) => {
    // Maintenance mode
    if (process.env.MAINTENANCE_MODE === 'true') {
      return <MaintenancePage />;
    }

    // Access control for protected paths
    if (path.startsWith('/members')) {
      const session = await getSession();
      if (!session) {
        return <LoginPage redirect={path} />;
      }
    }

    // Continue with normal rendering
    return null;
  },
});
```

#### `customMetadata` - Custom SEO Logic

Replace default metadata generation with custom logic:

```tsx
createCatchAllPage({
  getClient,
  config,
  customMetadata: async ({ content, path, preview, searchParams }) => {
    if (isEntryContent(content) && content.entry.type === 'product') {
      return {
        title: `${content.entry.title} | Shop`,
        openGraph: {
          type: 'product',
          // ... product-specific OG tags
        },
      };
    }

    // Fall back to default for other content
    return generatePageMetadata({
      page: content.page,
      site: content.site,
      path,
      siteUrl: 'https://example.com',
    });
  },
});
```

#### `wrapper` - Analytics, Error Boundaries

Wrap all rendered content with a component:

```tsx
createCatchAllPage({
  getClient,
  config,
  wrapper: ({ children }) => (
    <>
      {children}
      <AnalyticsScript siteId={config.siteId} />
    </>
  ),
});
```

### Configuration Options

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `getClient` | `() => RiverbankClient` | Yes | Factory function returning a configured client |
| `config` | `RiverbankSiteConfig` | Yes | Site config from `defineConfig()` |
| `blockOverrides` | `BlockOverrides` | No | Custom block component overrides |
| `siteUrl` | `string` | No | Override site URL for metadata (uses `config.liveUrl`/`previewUrl` as fallback) |
| `beforeRender` | `function` | No | Intercept before rendering |
| `customMetadata` | `function` | No | Custom metadata generation |
| `wrapper` | `ComponentType` | No | Wrapper component for rendered content |
| `useThemeScope` | `boolean` | No | Wrap rendered blocks in `ThemeScope` to inject theme CSS variables |
| `enablePortal` | `boolean` | No | Enable portal session token plumbing for customer portal blocks |
| `theme` | `Theme` | No | Static theme override (uses this instead of the CMS theme) |
| `themes` | `Record<string, Theme>` | No | Provide multiple themes for local iteration/demos |
| `themeSwitch` | `ThemeSwitchConfigV1` | No | Enable runtime switching when using `themes` (v1: query param) |
| `getTheme` | `(ctx) => Theme \| Promise<Theme>` | No | Escape hatch for per-request theme resolution |

### Local Theme Overrides (Edge-safe)

If you want to iterate on theme styling locally without pushing themes to the CMS, provide theme objects directly to `createCatchAllPage()`.

This is Edge-safe (no filesystem reads). Metadata generation is unchanged (it uses CMS page/site data, not theme).

```tsx
// src/app/[[...slug]]/page.tsx
import { createCatchAllPage } from '@riverbankcms/sdk/next';
import type { Theme } from '@riverbankcms/sdk/theme';
import themeBrand from './themes/brand.json';
import themeSeasonal from './themes/seasonal.json';
import { getRiverbankClient } from '@/lib/builder-client';
import config from '@/riverbank.config';

const { Page, generateMetadata } = createCatchAllPage({
  getClient: getRiverbankClient,
  config,
  themes: {
    brand: themeBrand as Theme,
    seasonal: themeSeasonal as Theme,
  },
  themeSwitch: { queryParam: 'theme', defaultKey: 'brand' },
});

export default Page;
export { generateMetadata };
// Demo: /?theme=seasonal
```

### Context Available in Escape Hatches

Both `beforeRender` and `customMetadata` receive a context object:

```typescript
type CatchAllContext = {
  content: LoadContentResult;  // Page or entry data (includes site data)
  path: string;                // Resolved path (e.g., '/blog/my-post')
  preview: boolean;            // Whether preview mode is enabled
  searchParams: Record<string, string | string[] | undefined>;  // URL search params
};
```

### Full Control - Use Lower-Level Helpers

For maximum customization, skip `createCatchAllPage` and use the underlying helpers:

```tsx
import { loadContent, isPageContent, Page } from '@riverbankcms/sdk/rendering';
import { generatePageMetadata } from '@riverbankcms/sdk/metadata';

export default async function CustomPage({ params }) {
  const { slug } = await params;
  const path = `/${slug?.join('/') || ''}`;

  const content = await loadContent({
    client: getClient(),
    siteId: config.siteId,
    path,
    preview: process.env.RIVERBANK_PREVIEW_MODE === 'true',
  });

  if (isPageContent(content)) {
    return <Page {...content} />;
  }

  // Custom entry rendering
  return <CustomEntryRenderer entry={content.entry} theme={content.theme} />;
}
```

## Spam Protection

Add ALTCHA proof-of-work spam protection to forms. The widget runs entirely client-side and requires no API keys.

### Setup

```tsx
'use client'

import { useSpamProtection, getChallengeUrl } from '@riverbankcms/sdk/spam-protection'

function ContactForm() {
  const { solution, isReady, reset, Widget } = useSpamProtection(getChallengeUrl())

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!isReady) return

    await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify({
        ...formData,
        altchaSolution: solution,  // Include with submission
      }),
    })
    reset()  // Reset for another submission
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <Widget />
      <button disabled={!isReady}>Submit</button>
    </form>
  )
}
```

### Environment

Set `NEXT_PUBLIC_DASHBOARD_URL` so `getChallengeUrl()` resolves correctly.

### Exports

```tsx
import {
  useSpamProtection,    // Hook for solution state management
  getChallengeUrl,      // Returns challenge endpoint URL
  hasSpamProtection,    // Check if spam protection is enabled (server-only)
  AltchaWidget,         // Low-level widget component
} from '@riverbankcms/sdk/spam-protection'
```

## Additional Exports

- `@riverbankcms/sdk/rendering` - Low-level rendering components
- `@riverbankcms/sdk/theme` - Theme utilities
- `@riverbankcms/sdk/theme-bridge` - Simplified theming for SDK sites
- `@riverbankcms/sdk/hooks` - (Deprecated: use `/client` instead)
- `@riverbankcms/sdk/data` - Data prefetching utilities
- `@riverbankcms/sdk/metadata` - Metadata generation helpers
- `@riverbankcms/sdk/routing` - Route resolution helpers
- `@riverbankcms/sdk/navigation` - Navigation helpers and types
- `@riverbankcms/sdk/analytics` - Analytics tracking helpers
- `@riverbankcms/sdk/config` - Site configuration utilities (includes `defineConfig`, `defineContentConfig`, `RiverbankSiteConfig`, `ContentConfig`, etc.)
- `@riverbankcms/sdk/next` - Next.js integration utilities (`createCatchAllPage`, ISR helpers, `generateStaticParams`)
- `@riverbankcms/sdk/prebuild` - Prebuild cache generation (`generatePrebuildCache`, `PrebuildLoader`)
- `@riverbankcms/sdk/spam-protection` - ALTCHA spam protection (`useSpamProtection`, `getChallengeUrl`, `AltchaWidget`)
