npm package: @harnonlabs/cms
Description: Headless CMS library for React with Cloudflare D1, inline editing, and admin components
Latest version: 0.6.0

# @harnonlabs/cms

A headless CMS library for React applications backed by Cloudflare D1 databases. Provides server-side CRUD API handlers and client-side inline editing components with preview/production mode support.

## Features

- **Server-side API handler** — Drop-in Next.js App Router route handlers for schema sync, CRUD, pagination, and publish
- **Inline editing** — contentEditable text editing and image picking with hover overlays
- **Admin components** — Entity list, record table, and record form components for building admin panels
- **Preview/production modes** — Edit in preview, publish to production with a single API call
- **D1 integration** — Built for Cloudflare D1 with parameterized queries and automatic schema management
- **Dual ESM/CJS** — Ships both ESM and CJS bundles with full TypeScript declarations

## Installation

```bash
npm install @harnonlabs/cms
```

### Peer dependencies

```bash
npm install react react-dom
```

Supports React 18 and 19.

## Database Setup

Create two D1 databases — one for preview and one for production:

```bash
npx wrangler d1 create cms-preview
npx wrangler d1 create cms-production
```

Add the bindings to your `wrangler.toml`:

```toml
[[d1_databases]]
binding = "CMS_PREVIEW_DB"
database_name = "cms-preview"
database_id = "<your-preview-database-id>"

[[d1_databases]]
binding = "CMS_PRODUCTION_DB"
database_name = "cms-production"
database_id = "<your-production-database-id>"
```

## API Route Setup

Create a catch-all API route in your Next.js App Router project:

```typescript
// app/api/cms/[...path]/route.ts
import { createCMSHandler } from "@harnonlabs/cms";
import type { CMSConfig } from "@harnonlabs/cms";

const config: CMSConfig = {
  getDB: (mode) => {
    // Return the appropriate D1 binding based on mode
    const env = process.env as unknown as {
      CMS_PREVIEW_DB: D1Database;
      CMS_PRODUCTION_DB: D1Database;
    };
    return mode === "production" ? env.CMS_PRODUCTION_DB : env.CMS_PREVIEW_DB;
  },
};

const handler = createCMSHandler(config);

export const GET = handler.GET;
export const POST = handler.POST;
export const PUT = handler.PUT;
export const DELETE = handler.DELETE;
```

### API Routes

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/cms/sync` | Sync entity schemas to both databases |
| GET | `/api/cms/entities` | List all CMS tables |
| GET | `/api/cms/entities/:name` | Get entity schema columns |
| GET | `/api/cms/entities/:name/records` | List records (supports `?limit=` and `?offset=`) |
| POST | `/api/cms/entities/:name/records` | Create a record |
| PUT | `/api/cms/entities/:name/records/:id` | Update a record |
| DELETE | `/api/cms/entities/:name/records/:id` | Delete a record |
| POST | `/api/cms/entities/:name/publish` | Copy preview data to production |

The database mode is read from the `X-CMS-Mode` header, then the `?mode=` query parameter, defaulting to `"preview"`.

## Layout Integration

Wrap your app with `CMSProvider` to enable inline editing:

```tsx
// app/layout.tsx
import { CMSProvider } from "@harnonlabs/cms/client";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <CMSProvider enabled={true} apiPath="/api/cms">
          {children}
        </CMSProvider>
      </body>
    </html>
  );
}
```

### CMSProvider Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `children` | `ReactNode` | — | Child components |
| `apiPath` | `string` | `"/api/cms"` | Base path for CMS API requests |
| `enabled` | `boolean` | `true` | Enable/disable CMS overlays |

### useCMS Hook

Access CMS state and actions from any child component:

```tsx
import { useCMS } from "@harnonlabs/cms/client";

function MyComponent() {
  const {
    entities,    // Map<string, EntityMeta>
    mode,        // "preview" | "production"
    enabled,     // boolean
    setMode,     // (mode: CMSMode) => void
    syncSchema,  // (entities: EntityMeta[]) => Promise<void>
    addRecord,   // (entityName, data?) => Promise<Record>
    updateField, // (entityName, recordId, field, value) => Promise<Record>
    deleteRecord,// (entityName, recordId) => Promise<void>
    publish,     // (entityName) => Promise<{ count: number }>
  } = useCMS();
}
```

## Page CMS Attributes

Annotate your page elements with data attributes to make them CMS-editable:

```tsx
// app/page.tsx
export default function HomePage() {
  return (
    <section data-cms-en="blog_post">
      <article data-cms-id="1">
        <h2 data-cms-prop="title">My First Post</h2>
        <p data-cms-prop="body">Post content goes here...</p>
        <img data-cms-prop="hero_image" src="/placeholder.jpg" alt="Hero" />
      </article>
    </section>
  );
}
```

### Attribute Reference

| Attribute | Description |
|-----------|-------------|
| `data-cms-en` | Marks an element as a CMS entity container. Value is the entity name (e.g., `"blog_post"`). |
| `data-cms-id` | Identifies a specific record within an entity. Value is the record ID. |
| `data-cms-prop` | Marks an element as an editable CMS field. Value is the field/column name. |

Entity names are automatically converted to table names with a `cms_` prefix (e.g., `blog_post` becomes `cms_blog_post`). Field types are inferred from the DOM element:

- `<img>` or elements with `background-image` style are stored as `TEXT` with the image flag
- `<input type="number">` or `<input type="range">` are stored as `REAL`
- Text content that parses as a number is stored as `REAL`
- Everything else is stored as `TEXT`

## Admin Panel Setup

Build an admin page using the provided components:

```tsx
// app/admin/page.tsx
"use client";

import { useState } from "react";
import { AdminEntityList } from "@harnonlabs/cms/client";
import { AdminRecordTable } from "@harnonlabs/cms/client";
import { AdminRecordForm } from "@harnonlabs/cms/client";

export default function AdminPage() {
  const [selectedEntity, setSelectedEntity] = useState<string | null>(null);
  const [mode, setMode] = useState<"preview" | "production">("preview");

  return (
    <div style={{ display: "flex" }}>
      <aside style={{ width: 240 }}>
        <AdminEntityList
          selectedEntity={selectedEntity}
          onSelectEntity={setSelectedEntity}
          mode={mode}
        />
      </aside>
      <main style={{ flex: 1 }}>
        <label>
          <input
            type="checkbox"
            checked={mode === "production"}
            onChange={(e) =>
              setMode(e.target.checked ? "production" : "preview")
            }
          />
          Production mode
        </label>
        {selectedEntity && (
          <AdminRecordTable entityName={selectedEntity} mode={mode} />
        )}
      </main>
    </div>
  );
}
```

### Admin Component Props

**AdminEntityList** — Sidebar listing discovered CMS entities.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `selectedEntity` | `string \| null` | — | Currently selected entity name |
| `onSelectEntity` | `(name: string) => void` | — | Called when an entity is clicked |
| `apiPath` | `string` | `"/api/cms"` | API base path |
| `mode` | `string` | `"preview"` | Database mode |

**AdminRecordTable** — Table displaying records for a selected entity.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `entityName` | `string` | — | Entity to display records for |
| `onEditRecord` | `(record) => void` | — | Called when edit button is clicked |
| `apiPath` | `string` | `"/api/cms"` | API base path |
| `mode` | `string` | `"preview"` | Database mode |

**AdminRecordForm** — Modal form for creating and editing records.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `entityName` | `string` | — | Entity name |
| `columns` | `ColumnInfo[]` | — | Schema columns for the entity |
| `record` | `Record \| null` | `null` | Record to edit, or null for create mode |
| `onClose` | `() => void` | — | Called when modal is closed |
| `onSaved` | `(record) => void` | — | Called after successful save |
| `apiPath` | `string` | `"/api/cms"` | API base path |
| `mode` | `string` | `"preview"` | Database mode |

## CLI

The package includes a CLI tool (`rlooper-cms`) to scaffold CMS files into a Next.js project.

### Quick Start

```bash
npx rlooper-cms init
```

Or install globally:

```bash
npm install -g @harnonlabs/cms
rlooper-cms init
```

### `init` Command

The `init` command runs an interactive wizard that detects your project setup and generates boilerplate files:

1. **Detects** Next.js, TypeScript, and the app directory (`src/app` or `app`)
2. **Prompts** you to configure:
   - API route base path (default: `/api/cms`)
   - Whether to generate an API route handler
   - Whether to wrap your root layout with `CMSProvider`
   - Whether to generate an admin panel page
   - Whether to set up Cloudflare D1 databases
3. **Generates** the selected files, creating directories as needed

#### Generated Files

| File | Description |
|------|-------------|
| `app/api/cms/[...path]/route.ts` | Catch-all API route handler with D1 integration |
| `app/layout.tsx` | Root layout wrapped with `CMSProvider` |
| `app/admin/page.tsx` | Admin panel with entity list and record table |
| `wrangler.toml` | Cloudflare D1 database bindings (if D1 setup selected) |

File extensions are `.ts`/`.tsx` or `.js`/`.jsx` depending on whether TypeScript is detected. If a file already exists, you are prompted before overwriting.

## Build and Deploy

```bash
# Build the library
npm run build

# Deploy your consuming application
npx wrangler deploy
```

## Types

All types are exported from the server entry point:

```typescript
import type {
  CMSMode,       // "preview" | "production"
  FieldMeta,     // { name, columnName, columnType, isImage }
  EntityMeta,    // { name, tableName, fields: FieldMeta[] }
  CMSConfig,     // { getDB: (mode: CMSMode) => D1Database }
  CMSHandlerResult, // { GET, POST, PUT, DELETE }
  SyncPayload,   // { entities: EntityMeta[] }
} from "@harnonlabs/cms";
```

## License

MIT