opacacms 0.1.20 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +638 -732
- package/dist/admin/auth-client.d.ts +39 -39
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.js +2397 -1405
- package/dist/admin/react.d.ts +1 -1
- package/dist/admin/react.js +8 -0
- package/dist/admin/router.d.ts +1 -0
- package/dist/admin/stores/ui.d.ts +10 -0
- package/dist/admin/ui/admin-layout.d.ts +4 -4
- package/dist/admin/ui/components/DataDetailView.d.ts +1 -1
- package/dist/admin/ui/components/DetailSheet.d.ts +19 -0
- package/dist/admin/ui/components/PluginSettingsForm.d.ts +11 -0
- package/dist/admin/ui/components/fields/BooleanField.d.ts +2 -1
- package/dist/admin/ui/components/fields/DateField.d.ts +1 -1
- package/dist/admin/ui/components/fields/FieldLabel.d.ts +11 -0
- package/dist/admin/ui/components/fields/FileField.d.ts +1 -1
- package/dist/admin/ui/components/fields/NumberField.d.ts +1 -1
- package/dist/admin/ui/components/fields/RadioField.d.ts +1 -1
- package/dist/admin/ui/components/fields/RelationshipField.d.ts +3 -1
- package/dist/admin/ui/components/fields/SelectField.d.ts +1 -1
- package/dist/admin/ui/components/fields/TextAreaField.d.ts +1 -1
- package/dist/admin/ui/components/fields/TextField.d.ts +1 -1
- package/dist/admin/ui/components/fields/VirtualField.d.ts +1 -0
- package/dist/admin/ui/components/fields/index.d.ts +16 -16
- package/dist/admin/ui/components/fields/richtext-editor/index.d.ts +1 -1
- package/dist/admin/ui/components/media/AssetManagerModal.d.ts +1 -1
- package/dist/admin/ui/components/toast.d.ts +1 -1
- package/dist/admin/ui/components/ui/accordion.d.ts +1 -1
- package/dist/admin/ui/components/ui/button.d.ts +1 -1
- package/dist/admin/ui/components/ui/collapsible.d.ts +1 -1
- package/dist/admin/ui/components/ui/dialog.d.ts +1 -1
- package/dist/admin/ui/components/ui/group.d.ts +1 -1
- package/dist/admin/ui/components/ui/index.d.ts +17 -17
- package/dist/admin/ui/components/ui/input.d.ts +1 -1
- package/dist/admin/ui/components/ui/label.d.ts +1 -1
- package/dist/admin/ui/components/ui/radio-group.d.ts +1 -1
- package/dist/admin/ui/components/ui/relationship.d.ts +4 -4
- package/dist/admin/ui/components/ui/select.d.ts +1 -1
- package/dist/admin/ui/components/ui/sheet.d.ts +1 -1
- package/dist/admin/ui/components/ui/tabs.d.ts +1 -1
- package/dist/admin/ui/components/versions-sheet.d.ts +11 -0
- package/dist/admin/ui/views/media-registry-view.d.ts +1 -1
- package/dist/admin/ui/views/settings-view.d.ts +2 -2
- package/dist/admin/vue.d.ts +17 -0
- package/dist/admin/vue.js +8 -0
- package/dist/admin/webcomponent.js +2 -2
- package/dist/admin.css +1 -1
- package/dist/auth/index.d.ts +101 -41
- package/dist/{chunk-qkn1ykrj.js → chunk-0bq155dy.js} +94 -31
- package/dist/{chunk-2z8wxx9g.js → chunk-0gtxnxmd.js} +98 -25
- package/dist/{chunk-v521d72w.js → chunk-3rdhbedb.js} +1 -1
- package/dist/chunk-51z3x7kq.js +20 -0
- package/dist/{chunk-7fyepksb.js → chunk-526a3gqx.js} +1 -1
- package/dist/{chunk-wmvjvn7b.js → chunk-6qq3ne6b.js} +39 -1
- package/dist/{chunk-0am1m47g.js → chunk-6v1fw7q7.js} +5 -5
- package/dist/{chunk-zvwb67nd.js → chunk-7y1nbmw6.js} +36 -5
- package/dist/chunk-8scgdznr.js +44 -0
- package/dist/{chunk-erh6x75p.js → chunk-b3kr8w41.js} +58 -7
- package/dist/chunk-bexcv7xe.js +36 -0
- package/dist/{chunk-16vgcf3k.js → chunk-byq8g0rd.js} +1 -1
- package/dist/{chunk-wq314kkx.js → chunk-d1asgtke.js} +94 -31
- package/dist/{chunk-xtwc125q.js → chunk-dykn5hr6.js} +8 -8
- package/dist/{chunk-pxh5encs.js → chunk-esrg9qj0.js} +102 -44
- package/dist/chunk-fj19qccp.js +78 -0
- package/dist/{chunk-x2ejaftz.js → chunk-gmee4mdc.js} +98 -27
- package/dist/{chunk-xa7rjsn2.js → chunk-j53pz21t.js} +2 -2
- package/dist/{chunk-9y3m1xkx.js → chunk-kc4jfnv7.js} +480 -85
- package/dist/chunk-mkn49zmy.js +102 -0
- package/dist/{chunk-n1xraw7j.js → chunk-qb6ztvw9.js} +1 -1
- package/dist/{chunk-2kyhqvhc.js → chunk-qxt9vge8.js} +1 -1
- package/dist/chunk-r39em4yj.js +29 -0
- package/dist/chunk-rqyjjqgy.js +91 -0
- package/dist/chunk-rsf0tpy1.js +8 -0
- package/dist/chunk-swtcpvhf.js +2442 -0
- package/dist/chunk-t0zg026p.js +71 -0
- package/dist/chunk-twpvxfce.js +64 -0
- package/dist/{chunk-ybbbqj63.js → chunk-v9z61v3g.js} +15 -0
- package/dist/{chunk-jwjk85ze.js → chunk-ywm4t2gm.js} +6 -2
- package/dist/cli/commands/plugin-build.d.ts +1 -0
- package/dist/cli/commands/plugin-init.d.ts +1 -0
- package/dist/cli/commands/plugin-sync.d.ts +1 -0
- package/dist/cli/index.js +24 -6
- package/dist/config-utils.d.ts +1 -1
- package/dist/config.d.ts +21 -4
- package/dist/db/better-sqlite.d.ts +1 -1
- package/dist/db/better-sqlite.js +5 -5
- package/dist/db/bun-sqlite.d.ts +1 -1
- package/dist/db/bun-sqlite.js +5 -5
- package/dist/db/d1.d.ts +1 -1
- package/dist/db/d1.js +5 -5
- package/dist/db/index.js +9 -9
- package/dist/db/postgres.d.ts +1 -1
- package/dist/db/postgres.js +5 -5
- package/dist/db/sqlite.d.ts +1 -1
- package/dist/db/sqlite.js +5 -5
- package/dist/index.js +4 -3
- package/dist/plugins/index.d.ts +1 -0
- package/dist/plugins/ui-bridge.d.ts +12 -0
- package/dist/plugins/utils.d.ts +5 -0
- package/dist/runtimes/bun.js +13 -7
- package/dist/runtimes/cloudflare-workers.js +5 -5
- package/dist/runtimes/next.js +5 -5
- package/dist/runtimes/node.js +13 -7
- package/dist/schema/collection.d.ts +9 -30
- package/dist/schema/fields/base.d.ts +3 -2
- package/dist/schema/fields/index.d.ts +12 -0
- package/dist/schema/fields/validation.test.d.ts +1 -0
- package/dist/schema/global.d.ts +10 -15
- package/dist/schema/index.js +22 -14
- package/dist/server/admin-router.d.ts +2 -2
- package/dist/server/admin.d.ts +2 -1
- package/dist/server/collection-router.d.ts +1 -1
- package/dist/server/handlers.d.ts +10 -0
- package/dist/server/middlewares/admin.d.ts +2 -2
- package/dist/server/middlewares/auth.d.ts +1 -1
- package/dist/server/middlewares/context.d.ts +2 -0
- package/dist/server/middlewares/rate-limit.d.ts +1 -1
- package/dist/server/openapi.d.ts +2 -0
- package/dist/server/plugins-loader.d.ts +6 -0
- package/dist/server/router.d.ts +3 -3
- package/dist/server/routers/admin.d.ts +2 -2
- package/dist/server/routers/auth.d.ts +1 -1
- package/dist/server/routers/collections.d.ts +1 -1
- package/dist/server/routers/plugins.d.ts +18 -0
- package/dist/server/setup-middlewares.d.ts +2 -2
- package/dist/server/system-router.d.ts +1 -1
- package/dist/server.js +11 -7
- package/dist/storage/adapters/local.d.ts +1 -1
- package/dist/storage/adapters/s3.d.ts +1 -1
- package/dist/types.d.ts +227 -12
- package/dist/utils/logger.d.ts +13 -35
- package/dist/validation.d.ts +60 -8
- package/dist/validator.d.ts +1 -1
- package/package.json +21 -7
- package/dist/admin/ui/components/DataDetailSheet.d.ts +0 -13
- package/dist/admin/ui/components/ui/relationship-detail-sheet.d.ts +0 -9
- package/dist/chunk-62ev8gnc.js +0 -41
- package/dist/chunk-j4d50hrx.js +0 -20
- package/dist/chunk-nb7ctdg8.js +0 -311
package/README.md
CHANGED
|
@@ -1,916 +1,801 @@
|
|
|
1
|
-
# OpacaCMS
|
|
1
|
+
# 🚀 OpacaCMS
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> **The Headless CMS that doesn't get in your way.** Define your schema, access control, and logic directly in TypeScript. No visual builders, no proprietary formats, no lock-in. Just code. 💻
|
|
4
4
|
|
|
5
|
-
OpacaCMS runs
|
|
5
|
+
OpacaCMS is a runtime-agnostic powerhouse that runs anywhere: **Node.js, Bun, Cloudflare Workers, and Next.js**. Powered by [Hono](https://hono.dev), it turns your schema into database tables and a high-performance REST API instantly. ⚡️
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
- [Getting Started](
|
|
12
|
-
- [Project Structure](
|
|
13
|
-
- [Configuration](
|
|
14
|
-
- [Collections](
|
|
15
|
-
- [Field Types](
|
|
16
|
-
- [
|
|
17
|
-
- [
|
|
18
|
-
- [
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
23
|
-
- [
|
|
24
|
-
- [
|
|
25
|
-
- [
|
|
26
|
-
- [
|
|
27
|
-
- [
|
|
28
|
-
- [
|
|
9
|
+
## 🧭 Quick Menu
|
|
10
|
+
|
|
11
|
+
- [✨ Getting Started](#-getting-started)
|
|
12
|
+
- [🏗 Project Structure](#-project-structure)
|
|
13
|
+
- [⚙️ Configuration](#-configuration)
|
|
14
|
+
- [📦 Collections](#-collections)
|
|
15
|
+
- [🧪 Field Types](#-field-types)
|
|
16
|
+
- [✅ Validation](#-validation)
|
|
17
|
+
- [🌍 Globals](#-globals)
|
|
18
|
+
- [🔐 Access Control](#-access-control)
|
|
19
|
+
- [⚓ Hooks](#-hooks)
|
|
20
|
+
- [🔔 Webhooks](#-webhooks)
|
|
21
|
+
- [📌 Versioning](#-versioning)
|
|
22
|
+
- [🧮 Virtual Fields](#-virtual-fields)
|
|
23
|
+
- [👤 Authentication](#-authentication)
|
|
24
|
+
- [📝 Logging](#-logging)
|
|
25
|
+
- [🗄 Database Adapters](#-database-adapters)
|
|
26
|
+
- [🔄 Migrations](#-migrations)
|
|
27
|
+
- [☁️ Storage](#-storage)
|
|
28
|
+
- [🌐 Internationalization (i18n)](#-internationalization-i18n)
|
|
29
|
+
- [🎨 Custom Admin Components](#-custom-admin-components)
|
|
30
|
+
- [🔌 API & SDK](#-the-client-sdk)
|
|
31
|
+
- [🏠 Full-Stack Examples](#-full-stack-examples)
|
|
29
32
|
|
|
30
33
|
---
|
|
31
34
|
|
|
32
|
-
## Getting Started
|
|
35
|
+
## ✨ Getting Started
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
You'll need [Bun](https://bun.sh) (highly recommended) or Node.js 18+.
|
|
35
38
|
|
|
36
39
|
```bash
|
|
37
|
-
#
|
|
38
|
-
bunx opacacms init my-cms
|
|
40
|
+
# Kickstart a new project
|
|
41
|
+
bunx opacacms init my-awesome-cms
|
|
39
42
|
|
|
40
|
-
cd my-cms
|
|
43
|
+
cd my-awesome-cms
|
|
41
44
|
bun install
|
|
42
45
|
bun dev
|
|
43
46
|
```
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
bun add opacacms
|
|
49
|
-
```
|
|
48
|
+
Adding to an existing project? Easy: `bun add opacacms` 📦
|
|
50
49
|
|
|
51
50
|
---
|
|
52
51
|
|
|
53
|
-
## Project Structure
|
|
52
|
+
## 🏗 Project Structure
|
|
54
53
|
|
|
55
|
-
```
|
|
54
|
+
```text
|
|
56
55
|
my-cms/
|
|
57
|
-
├── opacacms.config.ts ← schema +
|
|
58
|
-
├── migrations/ ←
|
|
59
|
-
├──
|
|
60
|
-
├── collections/ ← your collection definitions
|
|
56
|
+
├── opacacms.config.ts ← The heart of your CMS (schema + DB + auth)
|
|
57
|
+
├── migrations/ ← Your DB history
|
|
58
|
+
├── collections/ ← Where your data models live
|
|
61
59
|
│ ├── posts.ts
|
|
62
|
-
│
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
│ ├── products.ts
|
|
61
|
+
│ └── ...
|
|
62
|
+
├── globals/ ← Singleton documents (settings, etc.)
|
|
63
|
+
│ ├── site-settings.ts
|
|
64
|
+
│ └── ...
|
|
65
|
+
└── src/ ← Your app logic
|
|
67
66
|
```
|
|
68
67
|
|
|
69
68
|
---
|
|
70
69
|
|
|
71
|
-
## Configuration
|
|
70
|
+
## ⚙️ Configuration
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
Your `opacacms.config.ts` is the single source of truth. Export its configuration as the **default export**.
|
|
74
73
|
|
|
75
74
|
```typescript
|
|
76
|
-
|
|
77
|
-
import { defineConfig } from 'opacacms';
|
|
75
|
+
import { defineConfig } from 'opacacms/config';
|
|
78
76
|
import { createSQLiteAdapter } from 'opacacms/db/sqlite';
|
|
79
77
|
import { posts } from './collections/posts';
|
|
80
78
|
import { siteSettings } from './globals/site-settings';
|
|
81
79
|
|
|
82
80
|
export default defineConfig({
|
|
83
|
-
appName: 'My Blog',
|
|
81
|
+
appName: 'My Shiny Blog 💫',
|
|
84
82
|
serverURL: 'http://localhost:3000',
|
|
85
83
|
secret: process.env.OPACA_SECRET,
|
|
86
84
|
db: createSQLiteAdapter('local.db'),
|
|
87
|
-
collections: [posts
|
|
88
|
-
globals: [siteSettings
|
|
85
|
+
collections: [posts],
|
|
86
|
+
globals: [siteSettings],
|
|
87
|
+
i18n: {
|
|
88
|
+
locales: ['en', 'pt-BR', 'tr'],
|
|
89
|
+
defaultLocale: 'en',
|
|
90
|
+
},
|
|
91
|
+
auth: {
|
|
92
|
+
strategies: { emailPassword: true },
|
|
93
|
+
features: {
|
|
94
|
+
apiKeys: { enabled: true },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
logger: { level: 'debug' },
|
|
89
98
|
});
|
|
90
99
|
```
|
|
91
100
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
### Full config options
|
|
101
|
+
### Configuration Options
|
|
95
102
|
|
|
96
|
-
| Option | Type | Description
|
|
97
|
-
| ---------------- | -------------------------------- |
|
|
98
|
-
| `appName` | `string` |
|
|
99
|
-
| `serverURL` | `string` | The
|
|
100
|
-
| `secret` | `string` |
|
|
101
|
-
| `db` | `DatabaseAdapter` |
|
|
102
|
-
| `collections` | `Collection[]` |
|
|
103
|
-
| `globals` | `Global[]` |
|
|
104
|
-
| `
|
|
105
|
-
| `auth` | `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
103
|
+
| Option | Type | Description |
|
|
104
|
+
| ---------------- | -------------------------------- | ----------------------------------------------------------------- |
|
|
105
|
+
| `appName` | `string` | Display name shown in the Admin UI sidebar |
|
|
106
|
+
| `serverURL` | `string` | The base URL of your server (used for CORS, auth callbacks, etc.) |
|
|
107
|
+
| `secret` | `string` | Secret used for signing tokens and encryption |
|
|
108
|
+
| `db` | `DatabaseAdapter` | Database adapter (`createSQLiteAdapter`, `createD1Adapter`, etc.) |
|
|
109
|
+
| `collections` | `Collection[]` | Array of collection definitions |
|
|
110
|
+
| `globals` | `Global[]` | Array of global definitions |
|
|
111
|
+
| `i18n` | `{ locales, defaultLocale }` | Internationalization config |
|
|
112
|
+
| `auth` | `AuthConfig` | Authentication strategies and features |
|
|
113
|
+
| `logger` | `{ level, disabled? }` | Logger configuration |
|
|
114
|
+
| `trustedOrigins` | `string[]` | Origins allowed for CORS requests |
|
|
115
|
+
| `storages` | `Record<string, StorageAdapter>` | Named storage adapters for file uploads |
|
|
116
|
+
| `api` | `{ maxLimit? }` | API-level settings (e.g., max items per page) |
|
|
109
117
|
|
|
110
118
|
---
|
|
111
119
|
|
|
112
|
-
## Collections
|
|
120
|
+
## 📦 Collections
|
|
113
121
|
|
|
114
|
-
A **Collection**
|
|
122
|
+
A **Collection** is a database table + a REST API. Pure magic. ✨
|
|
115
123
|
|
|
116
124
|
```typescript
|
|
117
125
|
// collections/posts.ts
|
|
118
126
|
import { Collection, Field } from 'opacacms/schema';
|
|
119
127
|
|
|
120
|
-
export const posts = Collection.create('posts')
|
|
128
|
+
export const posts = Collection.create('posts')
|
|
121
129
|
.label('Blog Posts')
|
|
122
130
|
.icon('FileText')
|
|
123
|
-
.useAsTitle('title')
|
|
124
|
-
.versions({ drafts: true, maxRevisions: 10 })
|
|
125
|
-
.admin({
|
|
126
|
-
defaultColumns: ['title', 'slug', '_status', 'createdAt'],
|
|
127
|
-
views: [
|
|
128
|
-
{ name: 'Published', filter: { _status: { equals: 'published' } } },
|
|
129
|
-
{ name: 'Drafts', filter: { _status: { equals: 'draft' } } },
|
|
130
|
-
],
|
|
131
|
-
})
|
|
132
131
|
.fields([
|
|
133
|
-
Field.text('title').
|
|
132
|
+
Field.text('title').required().label('Post Title'),
|
|
134
133
|
Field.slug('slug').from('title').unique(),
|
|
135
134
|
Field.richText('content').localized(),
|
|
136
|
-
Field.relationship('author').to('_users').single()
|
|
137
|
-
Field.select('
|
|
138
|
-
.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
135
|
+
Field.relationship('author').to('_users').single(),
|
|
136
|
+
Field.select('status')
|
|
137
|
+
.options([
|
|
138
|
+
{ label: 'Draft', value: 'draft' },
|
|
139
|
+
{ label: 'Published', value: 'published' },
|
|
140
|
+
])
|
|
141
|
+
.defaultValue('draft'),
|
|
142
|
+
Field.checkbox('featured').label('Featured Post'),
|
|
143
|
+
])
|
|
144
|
+
.access({
|
|
145
|
+
read: () => true,
|
|
146
|
+
create: ({ user }) => !!user,
|
|
147
|
+
update: ({ user }) => user?.role === 'admin',
|
|
148
|
+
delete: ({ user }) => user?.role === 'admin',
|
|
149
|
+
})
|
|
150
|
+
.hooks({
|
|
151
|
+
beforeCreate: async (data) => {
|
|
152
|
+
// Mutate data before insertion
|
|
153
|
+
return { ...data, publishedAt: new Date().toISOString() };
|
|
154
|
+
},
|
|
155
|
+
afterCreate: async (doc) => {
|
|
156
|
+
console.log('New post created:', doc.id);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
153
159
|
```
|
|
154
160
|
|
|
155
|
-
### Collection Builder
|
|
156
|
-
|
|
157
|
-
| Method | Description |
|
|
158
|
-
| ------------------------- | ----------------------------------------------- |
|
|
159
|
-
| `Collection.create(slug)` | Start a new collection |
|
|
160
|
-
| `.label(string)` | Display name in the admin UI |
|
|
161
|
-
| `.icon(IconName)` | Lucide icon for the sidebar |
|
|
162
|
-
| `.useAsTitle(fieldName)` | Which field acts as the document title |
|
|
163
|
-
| `.timestamps(bool)` | Add `createdAt` / `updatedAt` (default: `true`) |
|
|
164
|
-
| `.fields([...])` | Define fields — see [Field Types](#field-types) |
|
|
165
|
-
| `.access(rules)` | Collection-level access control |
|
|
166
|
-
| `.versions(opts)` | Enable drafts & revisions |
|
|
167
|
-
| `.webhooks([...])` | Outbound HTTP callbacks on lifecycle events |
|
|
168
|
-
| `.admin(opts)` | Admin UI settings (columns, views, etc.) |
|
|
169
|
-
| `.virtual(name, opts)` | Add a computed field (not stored in DB) |
|
|
170
|
-
| `.computed(name, opts)` | Alias for `.virtual()` |
|
|
171
|
-
| `.build()` | Compile to the raw config object |
|
|
172
|
-
|
|
173
|
-
### Auto-generated API
|
|
174
|
-
|
|
175
|
-
For a collection with slug `posts`:
|
|
176
|
-
|
|
177
|
-
| Method | Endpoint | Description |
|
|
178
|
-
| -------- | ---------------- | -------------------------- |
|
|
179
|
-
| `GET` | `/api/posts` | List documents (paginated) |
|
|
180
|
-
| `GET` | `/api/posts/:id` | Get single document |
|
|
181
|
-
| `POST` | `/api/posts` | Create document |
|
|
182
|
-
| `PATCH` | `/api/posts/:id` | Update document |
|
|
183
|
-
| `DELETE` | `/api/posts/:id` | Delete document |
|
|
161
|
+
### Collection Builder Methods
|
|
184
162
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
163
|
+
| Method | Description |
|
|
164
|
+
| -------------------- | ---------------------------------------------------------------- |
|
|
165
|
+
| `.label(name)` | Sets the display name used in the Admin UI sidebar |
|
|
166
|
+
| `.icon(name)` | [Lucide](https://lucide.dev) icon name for the sidebar |
|
|
167
|
+
| `.fields([...])` | Defines the data structure for this collection |
|
|
168
|
+
| `.access(rules)` | Collection-level access control |
|
|
169
|
+
| `.hooks(fns)` | Lifecycle hooks (`beforeCreate`, `afterUpdate`, etc.) |
|
|
170
|
+
| `.webhooks([...])` | External webhook notifications |
|
|
171
|
+
| `.admin({...})` | Advanced Admin UI configuration (`hidden`, `disableAdmin`, etc.) |
|
|
172
|
+
| `.versions(true)` | Enable document versioning with history |
|
|
173
|
+
| `.timestamps({...})` | Customize timestamp field names |
|
|
188
174
|
|
|
189
175
|
---
|
|
190
176
|
|
|
191
|
-
## Field Types
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
Field.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
Field.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
177
|
+
## 🧪 Field Types
|
|
178
|
+
|
|
179
|
+
We've got everything you need to build powerful schemas:
|
|
180
|
+
|
|
181
|
+
| Field | Usage | Description |
|
|
182
|
+
| ---------------------- | ------------------------------------------- | --------------------------------------------- |
|
|
183
|
+
| `Field.text()` | `Field.text('title')` | Simple string input |
|
|
184
|
+
| `Field.number()` | `Field.number('price')` | Numeric input |
|
|
185
|
+
| `Field.richText()` | `Field.richText('content')` | Block-based Lexical editor (Notion style!) 📝 |
|
|
186
|
+
| `Field.relationship()` | `Field.relationship('author').to('_users')` | Links to another collection |
|
|
187
|
+
| `Field.file()` | `Field.file('image')` | File/image upload ☁️ |
|
|
188
|
+
| `Field.blocks()` | `Field.blocks('layout').blocks([...])` | Dynamic page builder 🧱 |
|
|
189
|
+
| `Field.group()` | `Field.group('meta').fields([...])` | Nested object group |
|
|
190
|
+
| `Field.array()` | `Field.array('tags').fields([...])` | Repeatable field group |
|
|
191
|
+
| `Field.select()` | `Field.select('status').options([...])` | Dropdown picker |
|
|
192
|
+
| `Field.checkbox()` | `Field.checkbox('active')` | Boolean toggle |
|
|
193
|
+
| `Field.slug()` | `Field.slug('slug').from('title')` | Auto-generated URL slug |
|
|
194
|
+
| `Field.date()` | `Field.date('publishedAt')` | Date/time picker |
|
|
195
|
+
| `Field.virtual()` | `Field.virtual('fullName').resolve(...)` | Computed field (not stored) |
|
|
196
|
+
| `Field.tabs()` | `Field.tabs('layout').tabs([...])` | UI-only grouping for the admin |
|
|
197
|
+
|
|
198
|
+
### Common Field Methods
|
|
199
|
+
|
|
200
|
+
Every field type inherits these chainable methods from the base builder:
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
Field.text('email')
|
|
204
|
+
.label('Email Address') // Admin UI label
|
|
205
|
+
.placeholder('you@example.com') // Input placeholder
|
|
206
|
+
.required() // Mark as required
|
|
207
|
+
.unique() // Unique constraint in the DB
|
|
208
|
+
.localized() // Enable per-locale values (i18n)
|
|
209
|
+
.defaultValue('hello@world.com') // Default value
|
|
210
|
+
.validate(z.string().email()) // Custom validation (function or Zod)
|
|
211
|
+
.access({ readOnly: true }) // Field-level access control
|
|
212
|
+
.description('Primary email') // Help text below the field
|
|
213
|
+
.hidden() // Hide from Admin UI
|
|
214
|
+
.readOnly() // Read-only in Admin UI
|
|
215
|
+
.admin({ components: { Field: 'my-custom-field' } }); // Custom component
|
|
227
216
|
```
|
|
228
217
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
```typescript
|
|
232
|
-
Field.select('status').choices(['draft', 'published', 'archived']);
|
|
233
|
-
|
|
234
|
-
Field.select('role').choices([
|
|
235
|
-
{ label: 'Admin', value: 'admin' },
|
|
236
|
-
{ label: 'Editor', value: 'editor' },
|
|
237
|
-
{ label: 'Viewer', value: 'viewer' },
|
|
238
|
-
]);
|
|
239
|
-
|
|
240
|
-
Field.radio('tier').choices([
|
|
241
|
-
{ label: 'Free', value: 'free' },
|
|
242
|
-
{ label: 'Pro', value: 'pro' },
|
|
243
|
-
{ label: 'Enterprise', value: 'enterprise' },
|
|
244
|
-
]);
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
### Rich Text
|
|
248
|
-
|
|
249
|
-
```typescript
|
|
250
|
-
Field.richText('body').localized();
|
|
251
|
-
// defaultMode: 'notion' (block-based) | 'simple' (toolbar)
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
### File Upload
|
|
255
|
-
|
|
256
|
-
```typescript
|
|
257
|
-
Field.file('avatar')
|
|
258
|
-
.bucket('default') // key in config.storages
|
|
259
|
-
.allowedmime_types(['image/jpeg', 'image/png', 'image/webp'])
|
|
260
|
-
.maxSize(5 * 1024 * 1024); // 5 MB
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
The uploaded file URL is stored inline in the document:
|
|
264
|
-
|
|
265
|
-
```json
|
|
266
|
-
{
|
|
267
|
-
"avatar": {
|
|
268
|
-
"url": "https://cdn.example.com/photo.jpg",
|
|
269
|
-
"filename": "photo.jpg",
|
|
270
|
-
"mime_type": "image/jpeg",
|
|
271
|
-
"filesize": 204800
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
### Relationship
|
|
218
|
+
---
|
|
277
219
|
|
|
278
|
-
|
|
279
|
-
// Single reference
|
|
280
|
-
Field.relationship('author').to('_users').single().displayField('name');
|
|
220
|
+
## ✅ Validation
|
|
281
221
|
|
|
282
|
-
|
|
283
|
-
Field.relationship('tags').to('tags').many();
|
|
222
|
+
OpacaCMS supports **granular field validation** via the `.validate()` method. You can pass either a **custom function** or a **Zod schema** — they're fully interchangeable.
|
|
284
223
|
|
|
285
|
-
|
|
286
|
-
import { products } from './products';
|
|
287
|
-
Field.relationship('related').to(products).many();
|
|
288
|
-
```
|
|
224
|
+
### Custom Function Validation
|
|
289
225
|
|
|
290
|
-
|
|
226
|
+
Return `true` to pass, or a `string` error message to fail:
|
|
291
227
|
|
|
292
228
|
```typescript
|
|
293
|
-
|
|
294
|
-
|
|
229
|
+
Field.text('username').validate((value) => {
|
|
230
|
+
if (value === 'admin') return "Username 'admin' is reserved";
|
|
231
|
+
return true;
|
|
232
|
+
});
|
|
295
233
|
```
|
|
296
234
|
|
|
297
|
-
###
|
|
235
|
+
### Zod Schema Validation
|
|
298
236
|
|
|
299
|
-
|
|
237
|
+
Pass any `z.ZodTypeAny` schema directly. Errors are automatically mapped:
|
|
300
238
|
|
|
301
239
|
```typescript
|
|
302
|
-
|
|
303
|
-
Field.select('theme').choices(['light', 'dark', 'system']),
|
|
304
|
-
Field.boolean('notifications').defaultValue(true),
|
|
305
|
-
);
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
### Blocks (dynamic content sections)
|
|
240
|
+
import { z } from 'zod';
|
|
309
241
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
```typescript
|
|
313
|
-
Field.blocks('content').blocks(
|
|
314
|
-
Field.block('hero')
|
|
315
|
-
.label('Hero Section')
|
|
316
|
-
.fields(
|
|
317
|
-
Field.text('headline').required(),
|
|
318
|
-
Field.text('subheadline'),
|
|
319
|
-
Field.text('ctaLabel'),
|
|
320
|
-
Field.text('ctaLink'),
|
|
321
|
-
),
|
|
322
|
-
Field.block('featureGrid')
|
|
323
|
-
.label('Feature Grid')
|
|
324
|
-
.fields(
|
|
325
|
-
Field.text('sectionTitle'),
|
|
326
|
-
Field.number('columns').defaultValue(3),
|
|
327
|
-
),
|
|
242
|
+
Field.text('cpf').validate(
|
|
243
|
+
z.string().regex(/^\d{3}\.\d{3}\.\d{3}-\d{2}$/, 'Invalid CPF format'),
|
|
328
244
|
);
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
### Layout fields (admin only — no database column)
|
|
332
245
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
246
|
+
Field.text('password').validate(
|
|
247
|
+
z
|
|
248
|
+
.string()
|
|
249
|
+
.min(8, 'Password must be at least 8 characters')
|
|
250
|
+
.regex(/[A-Z]/, 'Must contain at least one uppercase letter'),
|
|
338
251
|
);
|
|
339
252
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
.
|
|
343
|
-
.tab('SEO', Field.text('metaTitle'), Field.textarea('metaDescription'));
|
|
344
|
-
|
|
345
|
-
// Collapsible section
|
|
346
|
-
Field.collapsible()
|
|
347
|
-
.label('SEO Metadata')
|
|
348
|
-
.initiallyCollapsed()
|
|
349
|
-
.fields(
|
|
350
|
-
Field.text('metaTitle'),
|
|
351
|
-
Field.textarea('metaDescription'),
|
|
352
|
-
Field.text('canonicalUrl'),
|
|
353
|
-
);
|
|
253
|
+
Field.text('email')
|
|
254
|
+
.required()
|
|
255
|
+
.validate(z.string().email('Invalid email address'));
|
|
354
256
|
```
|
|
355
257
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
Computed at request time — never written to the database:
|
|
359
|
-
|
|
360
|
-
```typescript
|
|
361
|
-
export const users = Collection.create('users')
|
|
362
|
-
.fields([
|
|
363
|
-
Field.text('firstName').required(),
|
|
364
|
-
Field.text('lastName').required(),
|
|
365
|
-
])
|
|
366
|
-
.virtual('fullName', {
|
|
367
|
-
label: 'Full Name',
|
|
368
|
-
returnType: 'string',
|
|
369
|
-
resolve: ({ data }) => `${data.firstName} ${data.lastName}`,
|
|
370
|
-
});
|
|
371
|
-
```
|
|
258
|
+
> **💡 Tip:** Zod validation integrates seamlessly with `.required()`. The built-in requirement check runs first, then your Zod schema validates the value shape.
|
|
372
259
|
|
|
373
260
|
---
|
|
374
261
|
|
|
375
|
-
## Globals
|
|
262
|
+
## 🌍 Globals
|
|
376
263
|
|
|
377
|
-
|
|
264
|
+
Globals are **singleton documents** — perfect for site settings, navigation, footers, and other one-of-a-kind configs.
|
|
378
265
|
|
|
379
266
|
```typescript
|
|
380
|
-
// globals/site-settings.ts
|
|
381
267
|
import { Global, Field } from 'opacacms/schema';
|
|
382
268
|
|
|
383
269
|
export const siteSettings = Global.create('site-settings')
|
|
384
270
|
.label('Site Settings')
|
|
385
271
|
.icon('Settings')
|
|
386
272
|
.fields([
|
|
387
|
-
Field.text('siteName').required()
|
|
388
|
-
Field.text('
|
|
389
|
-
Field.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
// globals/header.ts
|
|
393
|
-
export const headerSettings = Global.create('header')
|
|
394
|
-
.label('Header')
|
|
395
|
-
.icon('Layout')
|
|
396
|
-
.fields([
|
|
397
|
-
Field.text('siteTitle').required(),
|
|
398
|
-
Field.json('navItems').label('Navigation Items'),
|
|
273
|
+
Field.text('siteName').required(),
|
|
274
|
+
Field.text('tagline').localized(),
|
|
275
|
+
Field.file('logo'),
|
|
276
|
+
Field.group('social').fields([Field.text('twitter'), Field.text('github')]),
|
|
399
277
|
]);
|
|
400
278
|
```
|
|
401
279
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
```typescript
|
|
405
|
-
export default defineConfig({
|
|
406
|
-
globals: [siteSettings.build(), headerSettings.build()],
|
|
407
|
-
});
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
### Global Builder — all methods
|
|
411
|
-
|
|
412
|
-
| Method | Description |
|
|
413
|
-
| ------------------------ | ----------------------------------------------------- |
|
|
414
|
-
| `Global.create(slug)` | Start a new global |
|
|
415
|
-
| `.label(string)` | Admin display name |
|
|
416
|
-
| `.icon(IconName)` | Lucide icon |
|
|
417
|
-
| `.timestamps(bool/opts)` | Enable/disable or rename timestamps (default: `true`) |
|
|
418
|
-
| `.fields([...])` | Field definitions |
|
|
419
|
-
| `.access(rules)` | Who can read / update this global |
|
|
420
|
-
| `.virtual(name, opts)` | Add a computed field |
|
|
421
|
-
| `.build()` | Compile to the raw config object |
|
|
422
|
-
|
|
423
|
-
**API endpoints:**
|
|
424
|
-
|
|
425
|
-
| Method | Endpoint | Description |
|
|
426
|
-
| ------- | ---------------------------- | ------------------- |
|
|
427
|
-
| `GET` | `/api/globals/site-settings` | Read the document |
|
|
428
|
-
| `PATCH` | `/api/globals/site-settings` | Update the document |
|
|
280
|
+
API: `GET /api/globals/site-settings` and `PUT /api/globals/site-settings`
|
|
429
281
|
|
|
430
282
|
---
|
|
431
283
|
|
|
432
|
-
## Access Control
|
|
284
|
+
## 🔐 Access Control
|
|
433
285
|
|
|
434
|
-
|
|
286
|
+
Secure your data with simple functions at both **collection** and **field** levels. 🛡️
|
|
435
287
|
|
|
436
|
-
### Collection-
|
|
288
|
+
### Collection-Level Access
|
|
437
289
|
|
|
438
290
|
```typescript
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
});
|
|
291
|
+
.access({
|
|
292
|
+
read: ({ user }) => !!user, // Logged in? You're good.
|
|
293
|
+
create: ({ user }) => user?.role === 'admin', // Only admins please!
|
|
294
|
+
update: ({ data, user }) => data.ownerId === user.id, // Only your own stuff.
|
|
295
|
+
delete: ({ user }) => user?.role === 'admin',
|
|
296
|
+
requireApiKey: true, // Require API key for programmatic access
|
|
297
|
+
})
|
|
447
298
|
```
|
|
448
299
|
|
|
449
|
-
|
|
300
|
+
### Field-Level Access
|
|
450
301
|
|
|
451
|
-
|
|
302
|
+
Control visibility and editability per-field:
|
|
452
303
|
|
|
453
304
|
```typescript
|
|
454
|
-
Field.
|
|
455
|
-
|
|
456
|
-
})
|
|
457
|
-
|
|
458
|
-
Field.textarea('internalNotes').access({
|
|
459
|
-
hidden: ({ user }) => user?.role !== 'admin', // stripped from API responses
|
|
305
|
+
Field.text('internalNotes').access({
|
|
306
|
+
hidden: ({ user }) => user?.role !== 'admin', // Only admins see this
|
|
307
|
+
readOnly: ({ operation }) => operation === 'update', // Editable only on create
|
|
308
|
+
disabled: false,
|
|
460
309
|
});
|
|
461
310
|
```
|
|
462
311
|
|
|
463
|
-
###
|
|
312
|
+
### Role-Based Access Control (RBAC)
|
|
313
|
+
|
|
314
|
+
Combine `auth` with the `access` property to define granular permissions:
|
|
464
315
|
|
|
465
316
|
```typescript
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
317
|
+
access: {
|
|
318
|
+
roles: {
|
|
319
|
+
admin: {
|
|
320
|
+
posts: ['read', 'create', 'update', 'delete'],
|
|
321
|
+
users: ['read', 'update']
|
|
322
|
+
},
|
|
323
|
+
editor: {
|
|
324
|
+
posts: ['read', 'update']
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
469
328
|
```
|
|
470
329
|
|
|
471
330
|
---
|
|
472
331
|
|
|
473
|
-
## Hooks
|
|
474
|
-
|
|
475
|
-
Run async logic during a document's lifecycle. Hooks can transform data before it is written or trigger side effects after.
|
|
476
|
-
|
|
477
|
-
```typescript
|
|
478
|
-
export const posts = Collection.create('posts')
|
|
479
|
-
.fields([
|
|
480
|
-
Field.text('title').required(),
|
|
481
|
-
Field.slug('slug').from('title').unique(),
|
|
482
|
-
Field.number('views'),
|
|
483
|
-
])
|
|
484
|
-
.hooks({
|
|
485
|
-
beforeCreate: async (data) => {
|
|
486
|
-
data.views = 0;
|
|
487
|
-
return data;
|
|
488
|
-
},
|
|
489
|
-
afterCreate: async (doc) => {
|
|
490
|
-
await notifySlack(`New post: ${doc.title}`);
|
|
491
|
-
},
|
|
492
|
-
beforeUpdate: async (data) => data,
|
|
493
|
-
afterUpdate: async (doc) => {
|
|
494
|
-
/* ... */
|
|
495
|
-
},
|
|
496
|
-
beforeDelete: async (id) => {
|
|
497
|
-
/* ... */
|
|
498
|
-
},
|
|
499
|
-
afterDelete: async (id) => {
|
|
500
|
-
/* ... */
|
|
501
|
-
},
|
|
502
|
-
});
|
|
503
|
-
```
|
|
332
|
+
## ⚓ Hooks
|
|
504
333
|
|
|
505
|
-
|
|
334
|
+
Hooks let you run side-effects at key points in the document lifecycle. They receive the document data and can mutate it before persistence.
|
|
506
335
|
|
|
507
336
|
```typescript
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
337
|
+
.hooks({
|
|
338
|
+
beforeCreate: async (data) => {
|
|
339
|
+
// Transform or enrich data before saving
|
|
340
|
+
data.slug = data.title.toLowerCase().replace(/\s+/g, '-');
|
|
341
|
+
return data;
|
|
342
|
+
},
|
|
343
|
+
afterCreate: async (doc) => {
|
|
344
|
+
// Side-effects after the document is saved
|
|
345
|
+
await sendWelcomeEmail(doc.email);
|
|
346
|
+
},
|
|
347
|
+
beforeUpdate: async (data) => {
|
|
348
|
+
data.updatedBy = 'system';
|
|
349
|
+
return data;
|
|
350
|
+
},
|
|
351
|
+
afterUpdate: async (doc) => {
|
|
352
|
+
await invalidateCache(`/posts/${doc.slug}`);
|
|
353
|
+
},
|
|
354
|
+
beforeDelete: async (id) => {
|
|
355
|
+
await archiveDocument(id);
|
|
356
|
+
},
|
|
357
|
+
afterDelete: async (id) => {
|
|
358
|
+
console.log(`Document ${id} deleted`);
|
|
359
|
+
},
|
|
360
|
+
})
|
|
517
361
|
```
|
|
518
362
|
|
|
519
363
|
---
|
|
520
364
|
|
|
521
|
-
##
|
|
365
|
+
## 🔔 Webhooks
|
|
522
366
|
|
|
523
|
-
|
|
367
|
+
Send HTTP notifications to external services when documents change. Webhooks run in the background using `waitUntil` on supported runtimes (Cloudflare Workers, Vercel Edge).
|
|
524
368
|
|
|
525
369
|
```typescript
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
enabled: true,
|
|
532
|
-
sendEmail: async ({ email, url }) => {
|
|
533
|
-
await sendTransactionalEmail({ to: email, loginUrl: url });
|
|
534
|
-
},
|
|
535
|
-
},
|
|
536
|
-
},
|
|
537
|
-
socialProviders: {
|
|
538
|
-
github: {
|
|
539
|
-
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
540
|
-
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
541
|
-
},
|
|
542
|
-
},
|
|
543
|
-
features: {
|
|
544
|
-
apiKeys: { enabled: true },
|
|
545
|
-
},
|
|
370
|
+
Collection.create('orders').webhooks([
|
|
371
|
+
{
|
|
372
|
+
url: 'https://hooks.slack.com/services/xxx',
|
|
373
|
+
events: ['afterCreate', 'afterUpdate'],
|
|
374
|
+
headers: { Authorization: 'Bearer my-token' },
|
|
546
375
|
},
|
|
547
|
-
|
|
376
|
+
{
|
|
377
|
+
url: 'https://api.example.com/webhooks/orders',
|
|
378
|
+
events: ['afterDelete'],
|
|
379
|
+
},
|
|
380
|
+
]);
|
|
548
381
|
```
|
|
549
382
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
### API Keys
|
|
553
|
-
|
|
554
|
-
When `apiKeys` is enabled, clients can authenticate using `Authorization: Bearer <key>` instead of cookies — useful for server-to-server calls and webhooks.
|
|
383
|
+
Supported events: `afterCreate`, `afterUpdate`, `afterDelete`.
|
|
555
384
|
|
|
556
385
|
---
|
|
557
386
|
|
|
558
|
-
##
|
|
387
|
+
## 📌 Versioning
|
|
559
388
|
|
|
560
|
-
|
|
389
|
+
Enable document versioning to keep a full history of changes. Each save creates a snapshot that can be restored later.
|
|
561
390
|
|
|
562
391
|
```typescript
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
392
|
+
Collection.create('posts')
|
|
393
|
+
.versions(true) // That's it!
|
|
394
|
+
.fields([...])
|
|
395
|
+
```
|
|
566
396
|
|
|
567
|
-
|
|
568
|
-
import { createBetterSQLiteAdapter } from 'opacacms/db/better-sqlite';
|
|
569
|
-
db: createBetterSQLiteAdapter('local.db');
|
|
397
|
+
### Version API
|
|
570
398
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
399
|
+
| Endpoint | Method | Description |
|
|
400
|
+
| ---------------------------------------- | ------ | -------------------------------- |
|
|
401
|
+
| `/api/posts/versions?parentId=xxx` | `GET` | List all versions for a document |
|
|
402
|
+
| `/api/posts/versions/:versionId/restore` | `POST` | Restore a specific version |
|
|
574
403
|
|
|
575
|
-
|
|
576
|
-
import { createPostgresAdapter } from 'opacacms/db/postgres';
|
|
577
|
-
db: createPostgresAdapter(process.env.DATABASE_URL);
|
|
404
|
+
The admin UI provides a visual "Versions" panel where editors can browse and restore past versions.
|
|
578
405
|
|
|
579
|
-
|
|
580
|
-
import { createD1Adapter } from 'opacacms/db/d1';
|
|
581
|
-
db: createD1Adapter(env.DB);
|
|
582
|
-
```
|
|
406
|
+
---
|
|
583
407
|
|
|
584
|
-
|
|
408
|
+
## 🧮 Virtual Fields
|
|
409
|
+
|
|
410
|
+
Virtual fields are **computed at read-time** and never stored in the database. Perfect for derived values, aggregated data, or API mashups.
|
|
585
411
|
|
|
586
412
|
```typescript
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
pushDestructive: false, // if true, drops stale columns/tables
|
|
413
|
+
Field.virtual('fullName').resolve(async ({ data, user, req }) => {
|
|
414
|
+
return `${data.firstName} ${data.lastName}`;
|
|
590
415
|
});
|
|
591
416
|
```
|
|
592
417
|
|
|
418
|
+
Virtual fields receive the full document `data`, the current `user`, `session`, `apiKey`, and the Hono `req` context.
|
|
419
|
+
|
|
593
420
|
---
|
|
594
421
|
|
|
595
|
-
##
|
|
422
|
+
## 👤 Authentication
|
|
596
423
|
|
|
597
|
-
|
|
424
|
+
OpacaCMS features a robust, built-in authentication system powered by [Better Auth](https://better-auth.com). It's secure by default and fully customizable.
|
|
598
425
|
|
|
599
|
-
###
|
|
426
|
+
### Basic Setup
|
|
600
427
|
|
|
601
|
-
```
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
428
|
+
```typescript
|
|
429
|
+
auth: {
|
|
430
|
+
strategies: {
|
|
431
|
+
emailPassword: true, // Enabled by default
|
|
432
|
+
magicLink: {
|
|
433
|
+
enabled: true,
|
|
434
|
+
sendEmail: async ({ email, url }) => {
|
|
435
|
+
await sendMyMagicLink(email, url);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
features: {
|
|
440
|
+
apiKeys: { enabled: true }, // Programmable access
|
|
441
|
+
mfa: { enabled: true, issuer: 'My App' } // Two-Factor Auth
|
|
442
|
+
}
|
|
443
|
+
}
|
|
605
444
|
```
|
|
606
445
|
|
|
607
|
-
|
|
446
|
+
### API Key Authentication
|
|
608
447
|
|
|
609
|
-
|
|
610
|
-
// migrations/20260319120000_initial-schema.ts
|
|
611
|
-
import type { OpacaMigrationDb } from 'opacacms/db';
|
|
612
|
-
|
|
613
|
-
export async function up(db: OpacaMigrationDb): Promise<void> {
|
|
614
|
-
await db.schema
|
|
615
|
-
.createTable('posts')
|
|
616
|
-
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
617
|
-
.addColumn('title', 'text', (col) => col.notNull())
|
|
618
|
-
.addColumn('slug', 'text', (col) => col.unique())
|
|
619
|
-
.addColumn('created_at', 'text', (col) =>
|
|
620
|
-
col.defaultTo('CURRENT_TIMESTAMP'),
|
|
621
|
-
)
|
|
622
|
-
.addColumn('updated_at', 'text', (col) =>
|
|
623
|
-
col.defaultTo('CURRENT_TIMESTAMP'),
|
|
624
|
-
)
|
|
625
|
-
.execute();
|
|
626
|
-
}
|
|
448
|
+
When `apiKeys` is enabled, you can create API keys with fine-grained collection permissions:
|
|
627
449
|
|
|
628
|
-
|
|
629
|
-
|
|
450
|
+
```typescript
|
|
451
|
+
// API keys can have per-collection permissions
|
|
452
|
+
{
|
|
453
|
+
permissions: {
|
|
454
|
+
posts: ['read', 'create'],
|
|
455
|
+
users: ['read']
|
|
456
|
+
}
|
|
630
457
|
}
|
|
631
458
|
```
|
|
632
459
|
|
|
633
|
-
|
|
460
|
+
Pass the key in your requests:
|
|
634
461
|
|
|
635
462
|
```bash
|
|
636
|
-
|
|
463
|
+
curl -H "Authorization: Bearer opaca_key_xxx" https://api.mycms.com/api/posts
|
|
637
464
|
```
|
|
638
465
|
|
|
639
|
-
|
|
466
|
+
---
|
|
640
467
|
|
|
641
|
-
|
|
642
|
-
bunx opacacms migrate:status
|
|
643
|
-
```
|
|
468
|
+
## 📝 Logging
|
|
644
469
|
|
|
645
|
-
|
|
470
|
+
OpacaCMS includes a configurable global logger that standardizes output across the core system and authentication events.
|
|
646
471
|
|
|
647
|
-
```
|
|
648
|
-
|
|
649
|
-
|
|
472
|
+
```typescript
|
|
473
|
+
logger: {
|
|
474
|
+
level: 'debug', // 'debug' | 'info' | 'warn' | 'error'
|
|
475
|
+
disabled: false,
|
|
476
|
+
disableColors: false
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Access the logger in custom middleware:
|
|
650
481
|
|
|
651
|
-
|
|
652
|
-
|
|
482
|
+
```typescript
|
|
483
|
+
const logger = c.get('logger');
|
|
484
|
+
logger.info('Custom route hit');
|
|
653
485
|
```
|
|
654
486
|
|
|
655
487
|
---
|
|
656
488
|
|
|
657
|
-
##
|
|
489
|
+
## 🗄 Database Adapters
|
|
658
490
|
|
|
659
|
-
|
|
491
|
+
OpacaCMS provides first-class adapters for multiple database engines. All adapters implement the same interface, so switching is as simple as changing one line.
|
|
660
492
|
|
|
661
|
-
|
|
493
|
+
| Adapter | Import | Usage |
|
|
494
|
+
| ------------- | -------------------- | --------------------------------- |
|
|
495
|
+
| SQLite (Bun) | `opacacms/db/sqlite` | `createSQLiteAdapter('local.db')` |
|
|
496
|
+
| Cloudflare D1 | `opacacms/db/d1` | `createD1Adapter(env.DB)` |
|
|
662
497
|
|
|
663
|
-
|
|
664
|
-
import { createS3Adapter } from 'opacacms/storage/s3';
|
|
498
|
+
---
|
|
665
499
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
}
|
|
500
|
+
## 🔄 Migrations
|
|
501
|
+
|
|
502
|
+
Migrations keep your database schema in sync with your collections. They're auto-generated from your field definitions.
|
|
503
|
+
|
|
504
|
+
```bash
|
|
505
|
+
# Create a migration
|
|
506
|
+
bunx opacacms migrate:create initial-schema
|
|
507
|
+
|
|
508
|
+
# Apply migrations
|
|
509
|
+
bunx opacacms migrate
|
|
677
510
|
```
|
|
678
511
|
|
|
679
|
-
|
|
512
|
+
When using `createBunHandler` or `createCloudflareWorkersHandler`, migrations run automatically on startup via `db.migrate(config.collections)`.
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## ☁️ Storage
|
|
517
|
+
|
|
518
|
+
OpacaCMS supports pluggable storage adapters for file uploads. You can define multiple named storages and reference them per-field.
|
|
680
519
|
|
|
681
520
|
```typescript
|
|
682
|
-
import {
|
|
521
|
+
import { createR2Storage } from 'opacacms/storage';
|
|
683
522
|
|
|
684
523
|
storages: {
|
|
685
|
-
default:
|
|
524
|
+
default: createR2Storage({
|
|
686
525
|
bucketBinding: env.BUCKET,
|
|
687
|
-
publicUrl: 'https://
|
|
526
|
+
publicUrl: 'https://cdn.example.com',
|
|
688
527
|
}),
|
|
689
|
-
secure:
|
|
528
|
+
secure: createR2Storage({
|
|
690
529
|
bucketBinding: env.SECURE_BUCKET,
|
|
691
|
-
publicUrl: 'https://
|
|
530
|
+
publicUrl: 'https://secure.example.com',
|
|
692
531
|
}),
|
|
693
532
|
}
|
|
694
533
|
```
|
|
695
534
|
|
|
696
|
-
|
|
535
|
+
---
|
|
697
536
|
|
|
698
|
-
|
|
699
|
-
import { createLocalAdapter } from 'opacacms/storage/local';
|
|
537
|
+
## 🌐 Internationalization (i18n)
|
|
700
538
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
539
|
+
Enable field-level localization with a simple config and the `.localized()` method on any field.
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
// Config
|
|
543
|
+
i18n: {
|
|
544
|
+
locales: ['en', 'pt-BR', 'tr'],
|
|
545
|
+
defaultLocale: 'en',
|
|
706
546
|
}
|
|
547
|
+
|
|
548
|
+
// Field
|
|
549
|
+
Field.text('title').localized()
|
|
550
|
+
Field.richText('content').localized()
|
|
707
551
|
```
|
|
708
552
|
|
|
709
|
-
|
|
553
|
+
### Locale Selection
|
|
710
554
|
|
|
711
|
-
|
|
555
|
+
Pass the desired locale in your API requests:
|
|
712
556
|
|
|
713
|
-
```
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
locales: ['en', 'pt-BR', 'es'],
|
|
717
|
-
defaultLocale: 'pt-BR',
|
|
718
|
-
},
|
|
719
|
-
});
|
|
720
|
-
```
|
|
557
|
+
```bash
|
|
558
|
+
# Via header
|
|
559
|
+
curl -H "x-opaca-locale: pt-BR" https://api.mycms.com/api/posts
|
|
721
560
|
|
|
722
|
-
|
|
561
|
+
# Via query parameter
|
|
562
|
+
curl https://api.mycms.com/api/posts?locale=pt-BR
|
|
723
563
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
Field.richText('body').localized();
|
|
727
|
-
Field.text('slug'); // not localized — same value across all locales
|
|
564
|
+
# Get all locales
|
|
565
|
+
curl https://api.mycms.com/api/posts?locale=all
|
|
728
566
|
```
|
|
729
567
|
|
|
730
|
-
|
|
568
|
+
When writing data, send the locale header and the value will be stored under that locale key automatically. The system handles merging — existing locale values are preserved.
|
|
731
569
|
|
|
732
570
|
---
|
|
733
571
|
|
|
734
|
-
## Custom Admin Components
|
|
572
|
+
## 🎨 Custom Admin Components
|
|
735
573
|
|
|
736
|
-
You can replace any field
|
|
574
|
+
This is where OpacaCMS shines. You can replace any field UI with your own **React** or **Vue** components via Web Components. 💅
|
|
737
575
|
|
|
738
|
-
### 1
|
|
576
|
+
### 1️⃣ React Components
|
|
739
577
|
|
|
740
|
-
```
|
|
741
|
-
//
|
|
742
|
-
import {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
(
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
},
|
|
754
|
-
unmount(container) {
|
|
755
|
-
(container as any)._root.unmount();
|
|
756
|
-
},
|
|
757
|
-
});
|
|
578
|
+
```tsx
|
|
579
|
+
// MyColorPicker.tsx
|
|
580
|
+
import { defineReactField } from 'opacacms/admin';
|
|
581
|
+
|
|
582
|
+
const ColorPicker = ({ value, onChange }) => (
|
|
583
|
+
<input
|
|
584
|
+
type="color"
|
|
585
|
+
value={value}
|
|
586
|
+
onChange={(e) => onChange(e.target.value)}
|
|
587
|
+
/>
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
defineReactField('my-color-picker', ColorPicker);
|
|
758
591
|
```
|
|
759
592
|
|
|
760
|
-
|
|
593
|
+
### 2️⃣ Vue Components
|
|
761
594
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
| `disabled` | `boolean` | True if access control disabled the field |
|
|
768
|
-
| `readOnly` | `boolean` | True if access control made it read-only |
|
|
769
|
-
| `error` | `string?` | Validation error message |
|
|
770
|
-
| `parentData` | `object?` | All other field values in the same document |
|
|
595
|
+
```tsx
|
|
596
|
+
// MyVuePicker.vue
|
|
597
|
+
import { defineVueField } from 'opacacms/admin';
|
|
598
|
+
import { createApp } from 'vue';
|
|
599
|
+
import MyVueComponent from './MyVueComponent.vue';
|
|
771
600
|
|
|
772
|
-
|
|
601
|
+
defineVueField('my-vue-picker', MyVueComponent, { createApp });
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### 3️⃣ Reference in Schema
|
|
773
605
|
|
|
774
606
|
```typescript
|
|
775
|
-
Field.text('
|
|
607
|
+
Field.text('color').admin({
|
|
776
608
|
components: {
|
|
777
|
-
Field: 'my-color-picker',
|
|
778
|
-
Cell: 'my-color-cell', // used in the collection list table
|
|
609
|
+
Field: 'my-color-picker',
|
|
779
610
|
},
|
|
780
611
|
});
|
|
781
612
|
```
|
|
782
613
|
|
|
783
|
-
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
## 🛠 Advanced Admin Configuration
|
|
617
|
+
|
|
618
|
+
Collections and Fields can be further customized for the Admin UI using the `.admin()` method.
|
|
619
|
+
|
|
620
|
+
### Collection Admin Options
|
|
621
|
+
|
|
622
|
+
| Option | Type | Description |
|
|
623
|
+
| ---------------- | ---------- | ------------------------------------------------------------------------------- |
|
|
624
|
+
| `hidden` | `boolean` | If true, hides the collection from the sidebar but keeps it accessible via URL. |
|
|
625
|
+
| `disableAdmin` | `boolean` | If true, completely removes the collection from the Admin UI. |
|
|
626
|
+
| `useAsTitle` | `string` | The field name to use as the title in breadcrumbs and lists. |
|
|
627
|
+
| `defaultColumns` | `string[]` | The default fields to show in the collection list table. |
|
|
784
628
|
|
|
785
|
-
|
|
629
|
+
Example:
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
export const InternalData = Collection.create('internal_data')
|
|
633
|
+
.admin({
|
|
634
|
+
hidden: true, // Only accessible via direct link
|
|
635
|
+
})
|
|
636
|
+
.fields([...]);
|
|
637
|
+
```
|
|
786
638
|
|
|
787
639
|
---
|
|
788
640
|
|
|
789
|
-
## The Client SDK
|
|
641
|
+
## 🔌 The Client SDK
|
|
790
642
|
|
|
791
|
-
|
|
643
|
+
Query your CMS like a pro with full type-safety. ⚡️
|
|
792
644
|
|
|
793
645
|
```typescript
|
|
794
646
|
import { createClient } from 'opacacms/client';
|
|
795
647
|
|
|
796
|
-
const cms = createClient({ baseURL: 'https://
|
|
648
|
+
const cms = createClient({ baseURL: 'https://api.mycms.com' });
|
|
797
649
|
|
|
798
|
-
|
|
799
|
-
const result = await cms.collections.posts.find({
|
|
800
|
-
'_status[equals]': 'published',
|
|
650
|
+
const posts = await cms.collections.posts.find({
|
|
801
651
|
limit: 10,
|
|
802
|
-
|
|
652
|
+
sort: 'createdAt:desc',
|
|
653
|
+
// Deep Populate! 🚀
|
|
654
|
+
populate: {
|
|
655
|
+
author: true,
|
|
656
|
+
comments: {
|
|
657
|
+
populate: {
|
|
658
|
+
user: true,
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
},
|
|
803
662
|
});
|
|
804
|
-
console.log(result.docs); // array of documents
|
|
805
|
-
console.log(result.totalPages);
|
|
806
|
-
|
|
807
|
-
// Single document
|
|
808
|
-
const post = await cms.collections.posts.findOne('abc-123');
|
|
809
|
-
|
|
810
|
-
// Create / Update / Delete
|
|
811
|
-
const newPost = await cms.collections.posts.create({ title: 'Hello' });
|
|
812
|
-
await cms.collections.posts.update('abc-123', { title: 'Updated' });
|
|
813
|
-
await cms.collections.posts.delete('abc-123');
|
|
814
|
-
|
|
815
|
-
// Globals
|
|
816
|
-
const settings = await cms.globals['site-settings'].get();
|
|
817
|
-
await cms.globals['site-settings'].update({ siteName: 'New Name' });
|
|
818
663
|
```
|
|
819
664
|
|
|
820
|
-
###
|
|
665
|
+
### Filtering & Querying
|
|
821
666
|
|
|
822
667
|
```bash
|
|
823
|
-
|
|
824
|
-
|
|
668
|
+
# Basic filter
|
|
669
|
+
GET /api/posts?status=published
|
|
825
670
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
import type { GeneratedTypes } from './opaca-types';
|
|
671
|
+
# Operator-based filtering
|
|
672
|
+
GET /api/posts?price[gt]=10&price[lt]=100
|
|
829
673
|
|
|
830
|
-
|
|
674
|
+
# Pagination
|
|
675
|
+
GET /api/posts?page=2&limit=20
|
|
831
676
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
677
|
+
# Sorting
|
|
678
|
+
GET /api/posts?sort=createdAt:desc
|
|
679
|
+
|
|
680
|
+
# Deep populate via REST
|
|
681
|
+
GET /api/posts?populate=author,comments.user
|
|
835
682
|
```
|
|
836
683
|
|
|
837
684
|
---
|
|
838
685
|
|
|
839
|
-
##
|
|
686
|
+
## 🏠 Full-Stack Examples
|
|
840
687
|
|
|
841
|
-
### Next.js
|
|
688
|
+
### Next.js (App Router)
|
|
842
689
|
|
|
843
|
-
|
|
690
|
+
OpacaCMS integrates with Next.js via the `createNextHandler` which wraps the internal Hono router using `hono/vercel`.
|
|
844
691
|
|
|
845
|
-
|
|
846
|
-
src/app/api/[[...route]]/route.ts
|
|
847
|
-
```
|
|
692
|
+
#### 1. API Route Handler
|
|
848
693
|
|
|
849
694
|
```typescript
|
|
850
|
-
//
|
|
695
|
+
// app/api/[[...route]]/route.ts
|
|
851
696
|
import { createNextHandler } from 'opacacms/runtimes/next';
|
|
852
|
-
import config from '
|
|
697
|
+
import config from '@/opacacms.config';
|
|
853
698
|
|
|
854
|
-
export const { GET, POST, PUT,
|
|
699
|
+
export const { GET, POST, PUT, DELETE, PATCH, OPTIONS } =
|
|
855
700
|
createNextHandler(config);
|
|
856
701
|
```
|
|
857
702
|
|
|
858
|
-
|
|
859
|
-
// opacacms.config.ts
|
|
860
|
-
import { defineConfig } from 'opacacms';
|
|
861
|
-
import { createPostgresAdapter } from 'opacacms/db/postgres';
|
|
703
|
+
#### 2. Admin UI Page
|
|
862
704
|
|
|
863
|
-
|
|
864
|
-
appName: 'My Next.js CMS',
|
|
865
|
-
serverURL: process.env.NEXT_PUBLIC_API_URL!,
|
|
866
|
-
secret: process.env.OPACA_SECRET!,
|
|
867
|
-
db: createPostgresAdapter(process.env.DATABASE_URL!),
|
|
868
|
-
collections: [
|
|
869
|
-
/* posts.build(), ... */
|
|
870
|
-
],
|
|
871
|
-
});
|
|
872
|
-
```
|
|
873
|
-
|
|
874
|
-
Serve the Admin UI from a Next.js page:
|
|
705
|
+
The admin interface is delivered as a **Web Component** — just import it in a client page and point it at your server:
|
|
875
706
|
|
|
876
707
|
```tsx
|
|
877
|
-
//
|
|
708
|
+
// app/admin/[[...segments]]/page.tsx
|
|
709
|
+
'use client';
|
|
710
|
+
|
|
711
|
+
import { useEffect, useState } from 'react';
|
|
712
|
+
import 'opacacms/admin/ui/styles/index.scss'; // Admin styles
|
|
713
|
+
|
|
714
|
+
// Declare the web component for TypeScript
|
|
715
|
+
declare module 'react' {
|
|
716
|
+
namespace JSX {
|
|
717
|
+
interface IntrinsicElements {
|
|
718
|
+
'opaca-admin': {
|
|
719
|
+
'server-url'?: string;
|
|
720
|
+
config?: string;
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
878
726
|
export default function AdminPage() {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
727
|
+
const [loaded, setLoaded] = useState(false);
|
|
728
|
+
|
|
729
|
+
useEffect(() => {
|
|
730
|
+
import('opacacms/admin/webcomponent')
|
|
731
|
+
.then(() => setLoaded(true))
|
|
732
|
+
.catch((err) => console.error('Failed to load Opaca Admin', err));
|
|
733
|
+
}, []);
|
|
734
|
+
|
|
735
|
+
if (!loaded) return <div>Loading Admin Interface...</div>;
|
|
736
|
+
|
|
737
|
+
return <opaca-admin server-url="http://localhost:3000" />;
|
|
887
738
|
}
|
|
888
739
|
```
|
|
889
740
|
|
|
741
|
+
That's it! Your full-stack Next.js app now has a complete CMS admin panel at `/admin` and a REST API at `/api`.
|
|
742
|
+
|
|
743
|
+
---
|
|
744
|
+
|
|
745
|
+
### Vue
|
|
746
|
+
|
|
747
|
+
For Vue, import the pre-built admin bundle and use the web component directly:
|
|
748
|
+
|
|
749
|
+
```vue
|
|
750
|
+
<script setup lang="ts">
|
|
751
|
+
import { onMounted, ref } from 'vue';
|
|
752
|
+
import 'opacacms/admin.css'; // Or the bundled CSS path
|
|
753
|
+
|
|
754
|
+
const loaded = ref(false);
|
|
755
|
+
|
|
756
|
+
onMounted(async () => {
|
|
757
|
+
try {
|
|
758
|
+
await import('opacacms/admin/webcomponent');
|
|
759
|
+
loaded.value = true;
|
|
760
|
+
} catch (err) {
|
|
761
|
+
console.error('Failed to load Opaca Admin', err);
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
</script>
|
|
765
|
+
|
|
766
|
+
<template>
|
|
767
|
+
<div v-if="!loaded">Loading Admin Interface...</div>
|
|
768
|
+
<opaca-admin v-else server-url="http://localhost:3000" />
|
|
769
|
+
</template>
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Your API server can run as a separate Bun or Node.js process using the standalone handler.
|
|
773
|
+
|
|
890
774
|
---
|
|
891
775
|
|
|
892
776
|
### Cloudflare Workers
|
|
893
777
|
|
|
778
|
+
OpacaCMS runs natively on Cloudflare Workers with D1 (database) and R2 (storage):
|
|
779
|
+
|
|
894
780
|
```typescript
|
|
895
781
|
// src/index.ts
|
|
896
782
|
import { createCloudflareWorkersHandler } from 'opacacms/runtimes/cloudflare-workers';
|
|
897
|
-
import
|
|
783
|
+
import config from './opacacms.config';
|
|
898
784
|
|
|
899
|
-
const app = createCloudflareWorkersHandler(
|
|
785
|
+
const app = createCloudflareWorkersHandler(config);
|
|
900
786
|
|
|
901
|
-
// Serve the
|
|
787
|
+
// Serve the admin SPA
|
|
902
788
|
app.get('/admin*', (c) => {
|
|
903
789
|
return c.html(`
|
|
904
790
|
<!DOCTYPE html>
|
|
905
791
|
<html>
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
<
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
</body>
|
|
792
|
+
<head>
|
|
793
|
+
<link rel="stylesheet" href="/admin.css">
|
|
794
|
+
</head>
|
|
795
|
+
<body>
|
|
796
|
+
<opaca-admin server-url="${new URL(c.req.url).origin}"></opaca-admin>
|
|
797
|
+
<script type="module" src="/webcomponent.js"></script>
|
|
798
|
+
</body>
|
|
914
799
|
</html>
|
|
915
800
|
`);
|
|
916
801
|
});
|
|
@@ -920,144 +805,165 @@ export default app;
|
|
|
920
805
|
|
|
921
806
|
```typescript
|
|
922
807
|
// opacacms.config.ts
|
|
923
|
-
import { defineConfig } from 'opacacms';
|
|
808
|
+
import { defineConfig } from 'opacacms/config';
|
|
924
809
|
import { createD1Adapter } from 'opacacms/db/d1';
|
|
925
|
-
import {
|
|
810
|
+
import { createR2Storage } from 'opacacms/storage';
|
|
926
811
|
|
|
927
|
-
// Export as a function to access per-request Cloudflare bindings
|
|
928
812
|
const getConfig = (env: Env, request: Request) =>
|
|
929
813
|
defineConfig({
|
|
930
|
-
appName: 'My
|
|
814
|
+
appName: 'My Edge CMS',
|
|
931
815
|
serverURL: new URL(request.url).origin,
|
|
932
816
|
secret: env.OPACA_SECRET,
|
|
933
|
-
trustedOrigins: ['https://my-frontend.com'],
|
|
934
817
|
db: createD1Adapter(env.DB),
|
|
935
818
|
storages: {
|
|
936
|
-
default:
|
|
819
|
+
default: createR2Storage({
|
|
937
820
|
bucketBinding: env.BUCKET,
|
|
938
821
|
publicUrl: new URL(request.url).origin,
|
|
939
822
|
}),
|
|
940
823
|
},
|
|
941
|
-
|
|
942
|
-
features: { apiKeys: { enabled: true } },
|
|
943
|
-
},
|
|
824
|
+
collections: [posts, products],
|
|
944
825
|
i18n: {
|
|
945
826
|
locales: ['en', 'pt-BR'],
|
|
946
|
-
defaultLocale: '
|
|
827
|
+
defaultLocale: 'en',
|
|
947
828
|
},
|
|
948
|
-
collections: [
|
|
949
|
-
/* posts.build(), ... */
|
|
950
|
-
],
|
|
951
|
-
globals: [
|
|
952
|
-
/* siteSettings.build(), ... */
|
|
953
|
-
],
|
|
954
829
|
});
|
|
955
830
|
|
|
956
831
|
export default getConfig;
|
|
957
832
|
```
|
|
958
833
|
|
|
959
|
-
```jsonc
|
|
960
|
-
// wrangler.jsonc
|
|
961
|
-
{
|
|
962
|
-
"name": "my-cms",
|
|
963
|
-
"compatibility_date": "2024-09-23",
|
|
964
|
-
"d1_databases": [
|
|
965
|
-
{ "binding": "DB", "database_name": "my-cms-db", "database_id": "..." },
|
|
966
|
-
],
|
|
967
|
-
"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "my-cms-assets" }],
|
|
968
|
-
}
|
|
969
|
-
```
|
|
970
|
-
|
|
971
834
|
---
|
|
972
835
|
|
|
973
|
-
### Bun (
|
|
836
|
+
### Bun (Standalone Server)
|
|
974
837
|
|
|
975
838
|
```typescript
|
|
976
|
-
// src/index.ts
|
|
977
839
|
import { createBunHandler } from 'opacacms/runtimes/bun';
|
|
978
840
|
import config from './opacacms.config';
|
|
979
841
|
|
|
980
|
-
const { init } = createBunHandler(config, { port: 3000 });
|
|
981
|
-
|
|
842
|
+
const { app, init } = createBunHandler(config, { port: 3000 });
|
|
843
|
+
|
|
844
|
+
await init(); // Connects DB, runs migrations, starts server
|
|
845
|
+
// 🚀 Listening on http://localhost:3000
|
|
982
846
|
```
|
|
983
847
|
|
|
984
848
|
---
|
|
985
849
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
```typescript
|
|
989
|
-
// src/index.ts
|
|
990
|
-
import { createNodeHandler } from 'opacacms/runtimes/node';
|
|
991
|
-
import config from './opacacms.config';
|
|
850
|
+
## Runtime Handlers
|
|
992
851
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
852
|
+
| Runtime | Import | Handler |
|
|
853
|
+
| ------------------------ | -------------------------------------- | ---------------------------------------- |
|
|
854
|
+
| **Next.js** (App Router) | `opacacms/runtimes/next` | `createNextHandler(config)` |
|
|
855
|
+
| **Bun** (Standalone) | `opacacms/runtimes/bun` | `createBunHandler(config, opts)` |
|
|
856
|
+
| **Cloudflare Workers** | `opacacms/runtimes/cloudflare-workers` | `createCloudflareWorkersHandler(config)` |
|
|
857
|
+
| **Node.js** | `opacacms/runtimes/node` | `createNodeHandler(config)` |
|
|
996
858
|
|
|
997
859
|
---
|
|
998
860
|
|
|
999
|
-
##
|
|
861
|
+
## 🌟 Why OpacaCMS?
|
|
1000
862
|
|
|
1001
|
-
|
|
863
|
+
- **Blazing Fast**: Built on Hono & Bun. 🚀
|
|
864
|
+
- **Truly Decoupled**: Your data is yours. No hidden SaaS lock-in.
|
|
865
|
+
- **Developer First**: Everything is a typed API. 👩💻
|
|
866
|
+
- **Deploy Anywhere**: Vercel, Cloudflare, Fly.io, or your own VPS.
|
|
867
|
+
- **Zod Validation**: First-class support for Zod schemas on any field.
|
|
868
|
+
- **Version History**: Full document versioning with one-click restore.
|
|
869
|
+
- **Edge-Ready**: Native Cloudflare D1 + R2 support for global deployments.
|
|
1002
870
|
|
|
1003
|
-
|
|
1004
|
-
# Scaffold a new project
|
|
1005
|
-
bunx opacacms init my-project
|
|
871
|
+
## Ready to build something awesome? [Let's go!](https://opacacms.com) 🎈
|
|
1006
872
|
|
|
1007
|
-
|
|
1008
|
-
bunx opacacms migrate:create <name>
|
|
873
|
+
## 🔌 Next-Gen Plugins
|
|
1009
874
|
|
|
1010
|
-
|
|
1011
|
-
|
|
875
|
+
OpacaCMS features a powerful, hook-based plugin system that allows you to extend the backend (schema, API middleware, routes) and the Admin UI (custom views, isolated dashboards) with full type-safety.
|
|
876
|
+
|
|
877
|
+
### The `definePlugin` Helper
|
|
878
|
+
|
|
879
|
+
Use `definePlugin` for a type-safe experience and rich metadata support.
|
|
1012
880
|
|
|
1013
|
-
|
|
1014
|
-
|
|
881
|
+
```typescript
|
|
882
|
+
// plugins/my-plugin.ts
|
|
883
|
+
import { definePlugin, html } from 'opacacms';
|
|
1015
884
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
885
|
+
export const myPlugin = () =>
|
|
886
|
+
definePlugin({
|
|
887
|
+
name: 'my-plugin',
|
|
888
|
+
label: 'Custom Dashboard',
|
|
889
|
+
description: 'A powerful extension for your CMS.',
|
|
890
|
+
version: '1.0.0',
|
|
891
|
+
icon: 'Activity',
|
|
1019
892
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
893
|
+
// 1. Hook into Global API Requests
|
|
894
|
+
onRequest: async (c) => {
|
|
895
|
+
if (c.req.path.startsWith('/api/secret')) {
|
|
896
|
+
console.log('Intercepted secret request!');
|
|
897
|
+
}
|
|
898
|
+
},
|
|
1023
899
|
|
|
1024
|
-
|
|
1025
|
-
|
|
900
|
+
// 2. Add API Routes & UI Assets
|
|
901
|
+
onRouterInit: (app) => {
|
|
902
|
+
// Serve the UI Registration Script
|
|
903
|
+
app.get('/api/plugins/my-plugin/setup.js', (c) => {
|
|
904
|
+
const js = `
|
|
905
|
+
// Use the simplified window.opaca helper
|
|
906
|
+
window.opaca.ui.registerAdminRoute({
|
|
907
|
+
label: "Plugin Dashboard",
|
|
908
|
+
icon: "Activity",
|
|
909
|
+
path: "/admin/my-plugin",
|
|
910
|
+
render: (serverUrl) => \`
|
|
911
|
+
<iframe
|
|
912
|
+
src="\${serverUrl}/api/plugins/my-plugin/view"
|
|
913
|
+
style="width:100%; height:calc(100vh - 100px); border:none;"
|
|
914
|
+
></iframe>
|
|
915
|
+
\`
|
|
916
|
+
});
|
|
917
|
+
`;
|
|
918
|
+
return (c.header('Content-Type', 'application/javascript'), c.body(js));
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
// Serve the Isolated HTML View with Hono/HTML
|
|
922
|
+
app.get('/api/plugins/my-plugin/view', (c) => {
|
|
923
|
+
return c.html(html`
|
|
924
|
+
<body
|
|
925
|
+
style="background: #f6f9fc; padding: 40px; font-family: sans-serif;"
|
|
926
|
+
>
|
|
927
|
+
<h1>Modern Plugin UI</h1>
|
|
928
|
+
<p>Isolated from CMS styles with zero boilerplate.</p>
|
|
929
|
+
</body>
|
|
930
|
+
`);
|
|
931
|
+
});
|
|
932
|
+
},
|
|
1026
933
|
|
|
1027
|
-
|
|
1028
|
-
|
|
934
|
+
// 3. Register Assets
|
|
935
|
+
adminAssets: () => ({
|
|
936
|
+
scripts: ['/api/plugins/my-plugin/setup.js'],
|
|
937
|
+
}),
|
|
938
|
+
});
|
|
1029
939
|
```
|
|
1030
940
|
|
|
1031
|
-
|
|
941
|
+
### Plugin Lifecycle Hooks
|
|
1032
942
|
|
|
1033
|
-
|
|
943
|
+
| Hook | Description |
|
|
944
|
+
| ---------------- | ---------------------------------------------------------------------------- |
|
|
945
|
+
| `onInit` | Runs during CMS startup. Used to inject collections or modify global config. |
|
|
946
|
+
| `onRequest` | Global middleware called for EVERY API request. Return `false` to block. |
|
|
947
|
+
| `onRouterInit` | Called when the API router is being built. Mount custom Hono routes here. |
|
|
948
|
+
| `onInitComplete` | Fired once all plugins and core modules are fully initialized. |
|
|
949
|
+
| `onDestroy` | Cleanup hook for graceful shutdown. |
|
|
950
|
+
| `onExport` | Hook for SSG (Static Site Generation) plugins to export custom files. |
|
|
1034
951
|
|
|
1035
|
-
###
|
|
952
|
+
### Global Admin Registry (`window.opaca`)
|
|
1036
953
|
|
|
1037
|
-
|
|
1038
|
-
db: createPostgresAdapter(process.env.DATABASE_URL, {
|
|
1039
|
-
push: false, // never auto-alter schema in production
|
|
1040
|
-
});
|
|
1041
|
-
```
|
|
954
|
+
Plugins can interact with the Admin UI via the `window.opaca` object:
|
|
1042
955
|
|
|
1043
|
-
|
|
956
|
+
- `window.opaca.ui.registerAdminRoute(item)`: Simplest way to add a new page to the sidebar.
|
|
957
|
+
- `window.opaca.ui.notify(message, type)`: Show a toast notification.
|
|
958
|
+
- `window.opaca.ui.toggleSidebar()`: Programmatically collapse/expand the menu.
|
|
1044
959
|
|
|
1045
|
-
|
|
1046
|
-
OPACA_SECRET=<random-32-char-string> # required
|
|
1047
|
-
DATABASE_URL=postgres://... # for Postgres
|
|
1048
|
-
```
|
|
960
|
+
### Registering the Plugin
|
|
1049
961
|
|
|
1050
|
-
|
|
962
|
+
Add your plugin to the `plugins` array in `opacacms.config.ts`:
|
|
1051
963
|
|
|
1052
|
-
```
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
bunx opacacms migrate:d1 --remote # Cloudflare D1
|
|
1058
|
-
|
|
1059
|
-
# Start the server
|
|
1060
|
-
node dist/index.js
|
|
1061
|
-
# or
|
|
1062
|
-
wrangler deploy
|
|
964
|
+
```typescript
|
|
965
|
+
export default defineConfig({
|
|
966
|
+
// ...
|
|
967
|
+
plugins: [myPlugin()],
|
|
968
|
+
});
|
|
1063
969
|
```
|