opacacms 0.1.21 → 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 +792 -50
- package/dist/admin/auth-client.d.ts +39 -39
- package/dist/admin/index.js +2360 -1392
- 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.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-0sdceeys.js → chunk-0bq155dy.js} +86 -6
- package/dist/{chunk-59sg3pw9.js → chunk-0gtxnxmd.js} +90 -7
- 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-t9v845m2.js → chunk-7y1nbmw6.js} +34 -3
- package/dist/chunk-8scgdznr.js +44 -0
- package/dist/{chunk-mycmsjd9.js → chunk-b3kr8w41.js} +57 -6
- package/dist/chunk-bexcv7xe.js +36 -0
- package/dist/{chunk-16vgcf3k.js → chunk-byq8g0rd.js} +1 -1
- package/dist/{chunk-fqastxq9.js → chunk-d1asgtke.js} +86 -6
- package/dist/{chunk-cpw2y3pn.js → chunk-dykn5hr6.js} +7 -7
- package/dist/{chunk-61kwqve4.js → chunk-esrg9qj0.js} +90 -9
- package/dist/chunk-fj19qccp.js +78 -0
- package/dist/{chunk-ekxkvqjm.js → chunk-gmee4mdc.js} +90 -9
- package/dist/{chunk-xa7rjsn2.js → chunk-j53pz21t.js} +2 -2
- package/dist/{chunk-xrfhhz85.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 -26
- 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 -7
- package/dist/schema/index.js +22 -6
- 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 +222 -15
- package/dist/utils/logger.d.ts +13 -35
- package/dist/validation.d.ts +40 -0
- 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
|
@@ -13,16 +13,22 @@ OpacaCMS is a runtime-agnostic powerhouse that runs anywhere: **Node.js, Bun, Cl
|
|
|
13
13
|
- [⚙️ Configuration](#-configuration)
|
|
14
14
|
- [📦 Collections](#-collections)
|
|
15
15
|
- [🧪 Field Types](#-field-types)
|
|
16
|
+
- [✅ Validation](#-validation)
|
|
16
17
|
- [🌍 Globals](#-globals)
|
|
17
18
|
- [🔐 Access Control](#-access-control)
|
|
18
19
|
- [⚓ Hooks](#-hooks)
|
|
20
|
+
- [🔔 Webhooks](#-webhooks)
|
|
21
|
+
- [📌 Versioning](#-versioning)
|
|
22
|
+
- [🧮 Virtual Fields](#-virtual-fields)
|
|
19
23
|
- [👤 Authentication](#-authentication)
|
|
24
|
+
- [📝 Logging](#-logging)
|
|
20
25
|
- [🗄 Database Adapters](#-database-adapters)
|
|
21
26
|
- [🔄 Migrations](#-migrations)
|
|
22
27
|
- [☁️ Storage](#-storage)
|
|
23
|
-
- [🌐 i18n](#-internationalization-i18n)
|
|
24
|
-
- [🎨 Custom Components](#-custom-admin-components)
|
|
28
|
+
- [🌐 Internationalization (i18n)](#-internationalization-i18n)
|
|
29
|
+
- [🎨 Custom Admin Components](#-custom-admin-components)
|
|
25
30
|
- [🔌 API & SDK](#-the-client-sdk)
|
|
31
|
+
- [🏠 Full-Stack Examples](#-full-stack-examples)
|
|
26
32
|
|
|
27
33
|
---
|
|
28
34
|
|
|
@@ -50,7 +56,12 @@ my-cms/
|
|
|
50
56
|
├── opacacms.config.ts ← The heart of your CMS (schema + DB + auth)
|
|
51
57
|
├── migrations/ ← Your DB history
|
|
52
58
|
├── collections/ ← Where your data models live
|
|
59
|
+
│ ├── posts.ts
|
|
60
|
+
│ ├── products.ts
|
|
61
|
+
│ └── ...
|
|
53
62
|
├── globals/ ← Singleton documents (settings, etc.)
|
|
63
|
+
│ ├── site-settings.ts
|
|
64
|
+
│ └── ...
|
|
54
65
|
└── src/ ← Your app logic
|
|
55
66
|
```
|
|
56
67
|
|
|
@@ -61,18 +72,49 @@ my-cms/
|
|
|
61
72
|
Your `opacacms.config.ts` is the single source of truth. Export its configuration as the **default export**.
|
|
62
73
|
|
|
63
74
|
```typescript
|
|
64
|
-
import { defineConfig } from 'opacacms';
|
|
75
|
+
import { defineConfig } from 'opacacms/config';
|
|
65
76
|
import { createSQLiteAdapter } from 'opacacms/db/sqlite';
|
|
66
77
|
import { posts } from './collections/posts';
|
|
78
|
+
import { siteSettings } from './globals/site-settings';
|
|
67
79
|
|
|
68
80
|
export default defineConfig({
|
|
69
81
|
appName: 'My Shiny Blog 💫',
|
|
70
82
|
serverURL: 'http://localhost:3000',
|
|
83
|
+
secret: process.env.OPACA_SECRET,
|
|
71
84
|
db: createSQLiteAdapter('local.db'),
|
|
72
|
-
collections: [posts],
|
|
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' },
|
|
73
98
|
});
|
|
74
99
|
```
|
|
75
100
|
|
|
101
|
+
### Configuration Options
|
|
102
|
+
|
|
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) |
|
|
117
|
+
|
|
76
118
|
---
|
|
77
119
|
|
|
78
120
|
## 📦 Collections
|
|
@@ -87,20 +129,48 @@ export const posts = Collection.create('posts')
|
|
|
87
129
|
.label('Blog Posts')
|
|
88
130
|
.icon('FileText')
|
|
89
131
|
.fields([
|
|
90
|
-
Field.text('title').required(),
|
|
132
|
+
Field.text('title').required().label('Post Title'),
|
|
91
133
|
Field.slug('slug').from('title').unique(),
|
|
92
134
|
Field.richText('content').localized(),
|
|
93
135
|
Field.relationship('author').to('_users').single(),
|
|
94
|
-
|
|
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
|
+
});
|
|
95
159
|
```
|
|
96
160
|
|
|
97
|
-
###
|
|
161
|
+
### Collection Builder Methods
|
|
98
162
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 |
|
|
104
174
|
|
|
105
175
|
---
|
|
106
176
|
|
|
@@ -108,23 +178,402 @@ export const posts = Collection.create('posts')
|
|
|
108
178
|
|
|
109
179
|
We've got everything you need to build powerful schemas:
|
|
110
180
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
216
|
+
```
|
|
118
217
|
|
|
119
218
|
---
|
|
120
219
|
|
|
121
|
-
##
|
|
220
|
+
## ✅ Validation
|
|
221
|
+
|
|
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.
|
|
223
|
+
|
|
224
|
+
### Custom Function Validation
|
|
225
|
+
|
|
226
|
+
Return `true` to pass, or a `string` error message to fail:
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
Field.text('username').validate((value) => {
|
|
230
|
+
if (value === 'admin') return "Username 'admin' is reserved";
|
|
231
|
+
return true;
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Zod Schema Validation
|
|
236
|
+
|
|
237
|
+
Pass any `z.ZodTypeAny` schema directly. Errors are automatically mapped:
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
import { z } from 'zod';
|
|
241
|
+
|
|
242
|
+
Field.text('cpf').validate(
|
|
243
|
+
z.string().regex(/^\d{3}\.\d{3}\.\d{3}-\d{2}$/, 'Invalid CPF format'),
|
|
244
|
+
);
|
|
245
|
+
|
|
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'),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
Field.text('email')
|
|
254
|
+
.required()
|
|
255
|
+
.validate(z.string().email('Invalid email address'));
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
> **💡 Tip:** Zod validation integrates seamlessly with `.required()`. The built-in requirement check runs first, then your Zod schema validates the value shape.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 🌍 Globals
|
|
263
|
+
|
|
264
|
+
Globals are **singleton documents** — perfect for site settings, navigation, footers, and other one-of-a-kind configs.
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import { Global, Field } from 'opacacms/schema';
|
|
268
|
+
|
|
269
|
+
export const siteSettings = Global.create('site-settings')
|
|
270
|
+
.label('Site Settings')
|
|
271
|
+
.icon('Settings')
|
|
272
|
+
.fields([
|
|
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')]),
|
|
277
|
+
]);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
API: `GET /api/globals/site-settings` and `PUT /api/globals/site-settings`
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## 🔐 Access Control
|
|
285
|
+
|
|
286
|
+
Secure your data with simple functions at both **collection** and **field** levels. 🛡️
|
|
287
|
+
|
|
288
|
+
### Collection-Level Access
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
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
|
+
})
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Field-Level Access
|
|
301
|
+
|
|
302
|
+
Control visibility and editability per-field:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
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,
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Role-Based Access Control (RBAC)
|
|
313
|
+
|
|
314
|
+
Combine `auth` with the `access` property to define granular permissions:
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
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
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## ⚓ Hooks
|
|
333
|
+
|
|
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.
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
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
|
+
})
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## 🔔 Webhooks
|
|
366
|
+
|
|
367
|
+
Send HTTP notifications to external services when documents change. Webhooks run in the background using `waitUntil` on supported runtimes (Cloudflare Workers, Vercel Edge).
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
Collection.create('orders').webhooks([
|
|
371
|
+
{
|
|
372
|
+
url: 'https://hooks.slack.com/services/xxx',
|
|
373
|
+
events: ['afterCreate', 'afterUpdate'],
|
|
374
|
+
headers: { Authorization: 'Bearer my-token' },
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
url: 'https://api.example.com/webhooks/orders',
|
|
378
|
+
events: ['afterDelete'],
|
|
379
|
+
},
|
|
380
|
+
]);
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Supported events: `afterCreate`, `afterUpdate`, `afterDelete`.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## 📌 Versioning
|
|
388
|
+
|
|
389
|
+
Enable document versioning to keep a full history of changes. Each save creates a snapshot that can be restored later.
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
Collection.create('posts')
|
|
393
|
+
.versions(true) // That's it!
|
|
394
|
+
.fields([...])
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Version API
|
|
398
|
+
|
|
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 |
|
|
403
|
+
|
|
404
|
+
The admin UI provides a visual "Versions" panel where editors can browse and restore past versions.
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
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.
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
Field.virtual('fullName').resolve(async ({ data, user, req }) => {
|
|
414
|
+
return `${data.firstName} ${data.lastName}`;
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Virtual fields receive the full document `data`, the current `user`, `session`, `apiKey`, and the Hono `req` context.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## 👤 Authentication
|
|
423
|
+
|
|
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.
|
|
425
|
+
|
|
426
|
+
### Basic Setup
|
|
427
|
+
|
|
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
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### API Key Authentication
|
|
447
|
+
|
|
448
|
+
When `apiKeys` is enabled, you can create API keys with fine-grained collection permissions:
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
// API keys can have per-collection permissions
|
|
452
|
+
{
|
|
453
|
+
permissions: {
|
|
454
|
+
posts: ['read', 'create'],
|
|
455
|
+
users: ['read']
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Pass the key in your requests:
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
curl -H "Authorization: Bearer opaca_key_xxx" https://api.mycms.com/api/posts
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## 📝 Logging
|
|
469
|
+
|
|
470
|
+
OpacaCMS includes a configurable global logger that standardizes output across the core system and authentication events.
|
|
471
|
+
|
|
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:
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
const logger = c.get('logger');
|
|
484
|
+
logger.info('Custom route hit');
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
## 🗄 Database Adapters
|
|
122
490
|
|
|
123
|
-
|
|
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.
|
|
124
492
|
|
|
125
|
-
|
|
493
|
+
| Adapter | Import | Usage |
|
|
494
|
+
| ------------- | -------------------- | --------------------------------- |
|
|
495
|
+
| SQLite (Bun) | `opacacms/db/sqlite` | `createSQLiteAdapter('local.db')` |
|
|
496
|
+
| Cloudflare D1 | `opacacms/db/d1` | `createD1Adapter(env.DB)` |
|
|
126
497
|
|
|
127
|
-
|
|
498
|
+
---
|
|
499
|
+
|
|
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
|
|
510
|
+
```
|
|
511
|
+
|
|
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.
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
import { createR2Storage } from 'opacacms/storage';
|
|
522
|
+
|
|
523
|
+
storages: {
|
|
524
|
+
default: createR2Storage({
|
|
525
|
+
bucketBinding: env.BUCKET,
|
|
526
|
+
publicUrl: 'https://cdn.example.com',
|
|
527
|
+
}),
|
|
528
|
+
secure: createR2Storage({
|
|
529
|
+
bucketBinding: env.SECURE_BUCKET,
|
|
530
|
+
publicUrl: 'https://secure.example.com',
|
|
531
|
+
}),
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
---
|
|
536
|
+
|
|
537
|
+
## 🌐 Internationalization (i18n)
|
|
538
|
+
|
|
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',
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Field
|
|
549
|
+
Field.text('title').localized()
|
|
550
|
+
Field.richText('content').localized()
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### Locale Selection
|
|
554
|
+
|
|
555
|
+
Pass the desired locale in your API requests:
|
|
556
|
+
|
|
557
|
+
```bash
|
|
558
|
+
# Via header
|
|
559
|
+
curl -H "x-opaca-locale: pt-BR" https://api.mycms.com/api/posts
|
|
560
|
+
|
|
561
|
+
# Via query parameter
|
|
562
|
+
curl https://api.mycms.com/api/posts?locale=pt-BR
|
|
563
|
+
|
|
564
|
+
# Get all locales
|
|
565
|
+
curl https://api.mycms.com/api/posts?locale=all
|
|
566
|
+
```
|
|
567
|
+
|
|
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.
|
|
569
|
+
|
|
570
|
+
---
|
|
571
|
+
|
|
572
|
+
## 🎨 Custom Admin Components
|
|
573
|
+
|
|
574
|
+
This is where OpacaCMS shines. You can replace any field UI with your own **React** or **Vue** components via Web Components. 💅
|
|
575
|
+
|
|
576
|
+
### 1️⃣ React Components
|
|
128
577
|
|
|
129
578
|
```tsx
|
|
130
579
|
// MyColorPicker.tsx
|
|
@@ -141,9 +590,7 @@ const ColorPicker = ({ value, onChange }) => (
|
|
|
141
590
|
defineReactField('my-color-picker', ColorPicker);
|
|
142
591
|
```
|
|
143
592
|
|
|
144
|
-
### 2️⃣ Vue
|
|
145
|
-
|
|
146
|
-
Same thing, just pass your `createApp` function!
|
|
593
|
+
### 2️⃣ Vue Components
|
|
147
594
|
|
|
148
595
|
```tsx
|
|
149
596
|
// MyVuePicker.vue
|
|
@@ -166,62 +613,357 @@ Field.text('color').admin({
|
|
|
166
613
|
|
|
167
614
|
---
|
|
168
615
|
|
|
169
|
-
##
|
|
616
|
+
## 🛠 Advanced Admin Configuration
|
|
617
|
+
|
|
618
|
+
Collections and Fields can be further customized for the Admin UI using the `.admin()` method.
|
|
170
619
|
|
|
171
|
-
|
|
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. |
|
|
628
|
+
|
|
629
|
+
Example:
|
|
172
630
|
|
|
173
631
|
```typescript
|
|
174
|
-
.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
632
|
+
export const InternalData = Collection.create('internal_data')
|
|
633
|
+
.admin({
|
|
634
|
+
hidden: true, // Only accessible via direct link
|
|
635
|
+
})
|
|
636
|
+
.fields([...]);
|
|
179
637
|
```
|
|
180
638
|
|
|
181
639
|
---
|
|
182
640
|
|
|
183
|
-
##
|
|
641
|
+
## 🔌 The Client SDK
|
|
642
|
+
|
|
643
|
+
Query your CMS like a pro with full type-safety. ⚡️
|
|
184
644
|
|
|
185
|
-
|
|
645
|
+
```typescript
|
|
646
|
+
import { createClient } from 'opacacms/client';
|
|
186
647
|
|
|
187
|
-
|
|
648
|
+
const cms = createClient({ baseURL: 'https://api.mycms.com' });
|
|
188
649
|
|
|
189
|
-
|
|
650
|
+
const posts = await cms.collections.posts.find({
|
|
651
|
+
limit: 10,
|
|
652
|
+
sort: 'createdAt:desc',
|
|
653
|
+
// Deep Populate! 🚀
|
|
654
|
+
populate: {
|
|
655
|
+
author: true,
|
|
656
|
+
comments: {
|
|
657
|
+
populate: {
|
|
658
|
+
user: true,
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
```
|
|
190
664
|
|
|
191
|
-
|
|
665
|
+
### Filtering & Querying
|
|
192
666
|
|
|
193
667
|
```bash
|
|
194
|
-
#
|
|
195
|
-
|
|
668
|
+
# Basic filter
|
|
669
|
+
GET /api/posts?status=published
|
|
196
670
|
|
|
197
|
-
#
|
|
198
|
-
|
|
671
|
+
# Operator-based filtering
|
|
672
|
+
GET /api/posts?price[gt]=10&price[lt]=100
|
|
673
|
+
|
|
674
|
+
# Pagination
|
|
675
|
+
GET /api/posts?page=2&limit=20
|
|
676
|
+
|
|
677
|
+
# Sorting
|
|
678
|
+
GET /api/posts?sort=createdAt:desc
|
|
679
|
+
|
|
680
|
+
# Deep populate via REST
|
|
681
|
+
GET /api/posts?populate=author,comments.user
|
|
199
682
|
```
|
|
200
683
|
|
|
201
684
|
---
|
|
202
685
|
|
|
203
|
-
##
|
|
686
|
+
## 🏠 Full-Stack Examples
|
|
204
687
|
|
|
205
|
-
|
|
688
|
+
### Next.js (App Router)
|
|
689
|
+
|
|
690
|
+
OpacaCMS integrates with Next.js via the `createNextHandler` which wraps the internal Hono router using `hono/vercel`.
|
|
691
|
+
|
|
692
|
+
#### 1. API Route Handler
|
|
206
693
|
|
|
207
694
|
```typescript
|
|
208
|
-
|
|
695
|
+
// app/api/[[...route]]/route.ts
|
|
696
|
+
import { createNextHandler } from 'opacacms/runtimes/next';
|
|
697
|
+
import config from '@/opacacms.config';
|
|
209
698
|
|
|
210
|
-
const
|
|
699
|
+
export const { GET, POST, PUT, DELETE, PATCH, OPTIONS } =
|
|
700
|
+
createNextHandler(config);
|
|
701
|
+
```
|
|
211
702
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
703
|
+
#### 2. Admin UI Page
|
|
704
|
+
|
|
705
|
+
The admin interface is delivered as a **Web Component** — just import it in a client page and point it at your server:
|
|
706
|
+
|
|
707
|
+
```tsx
|
|
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
|
+
|
|
726
|
+
export default function AdminPage() {
|
|
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" />;
|
|
738
|
+
}
|
|
739
|
+
```
|
|
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
|
+
}
|
|
215
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
|
+
|
|
774
|
+
---
|
|
775
|
+
|
|
776
|
+
### Cloudflare Workers
|
|
777
|
+
|
|
778
|
+
OpacaCMS runs natively on Cloudflare Workers with D1 (database) and R2 (storage):
|
|
779
|
+
|
|
780
|
+
```typescript
|
|
781
|
+
// src/index.ts
|
|
782
|
+
import { createCloudflareWorkersHandler } from 'opacacms/runtimes/cloudflare-workers';
|
|
783
|
+
import config from './opacacms.config';
|
|
784
|
+
|
|
785
|
+
const app = createCloudflareWorkersHandler(config);
|
|
786
|
+
|
|
787
|
+
// Serve the admin SPA
|
|
788
|
+
app.get('/admin*', (c) => {
|
|
789
|
+
return c.html(`
|
|
790
|
+
<!DOCTYPE html>
|
|
791
|
+
<html>
|
|
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>
|
|
799
|
+
</html>
|
|
800
|
+
`);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
export default app;
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
```typescript
|
|
807
|
+
// opacacms.config.ts
|
|
808
|
+
import { defineConfig } from 'opacacms/config';
|
|
809
|
+
import { createD1Adapter } from 'opacacms/db/d1';
|
|
810
|
+
import { createR2Storage } from 'opacacms/storage';
|
|
811
|
+
|
|
812
|
+
const getConfig = (env: Env, request: Request) =>
|
|
813
|
+
defineConfig({
|
|
814
|
+
appName: 'My Edge CMS',
|
|
815
|
+
serverURL: new URL(request.url).origin,
|
|
816
|
+
secret: env.OPACA_SECRET,
|
|
817
|
+
db: createD1Adapter(env.DB),
|
|
818
|
+
storages: {
|
|
819
|
+
default: createR2Storage({
|
|
820
|
+
bucketBinding: env.BUCKET,
|
|
821
|
+
publicUrl: new URL(request.url).origin,
|
|
822
|
+
}),
|
|
823
|
+
},
|
|
824
|
+
collections: [posts, products],
|
|
825
|
+
i18n: {
|
|
826
|
+
locales: ['en', 'pt-BR'],
|
|
827
|
+
defaultLocale: 'en',
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
export default getConfig;
|
|
216
832
|
```
|
|
217
833
|
|
|
218
834
|
---
|
|
219
835
|
|
|
836
|
+
### Bun (Standalone Server)
|
|
837
|
+
|
|
838
|
+
```typescript
|
|
839
|
+
import { createBunHandler } from 'opacacms/runtimes/bun';
|
|
840
|
+
import config from './opacacms.config';
|
|
841
|
+
|
|
842
|
+
const { app, init } = createBunHandler(config, { port: 3000 });
|
|
843
|
+
|
|
844
|
+
await init(); // Connects DB, runs migrations, starts server
|
|
845
|
+
// 🚀 Listening on http://localhost:3000
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
---
|
|
849
|
+
|
|
850
|
+
## Runtime Handlers
|
|
851
|
+
|
|
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)` |
|
|
858
|
+
|
|
859
|
+
---
|
|
860
|
+
|
|
220
861
|
## 🌟 Why OpacaCMS?
|
|
221
862
|
|
|
222
863
|
- **Blazing Fast**: Built on Hono & Bun. 🚀
|
|
223
864
|
- **Truly Decoupled**: Your data is yours. No hidden SaaS lock-in.
|
|
224
865
|
- **Developer First**: Everything is a typed API. 👩💻
|
|
225
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.
|
|
870
|
+
|
|
871
|
+
## Ready to build something awesome? [Let's go!](https://opacacms.com) 🎈
|
|
872
|
+
|
|
873
|
+
## 🔌 Next-Gen Plugins
|
|
226
874
|
|
|
227
|
-
|
|
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.
|
|
880
|
+
|
|
881
|
+
```typescript
|
|
882
|
+
// plugins/my-plugin.ts
|
|
883
|
+
import { definePlugin, html } from 'opacacms';
|
|
884
|
+
|
|
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',
|
|
892
|
+
|
|
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
|
+
},
|
|
899
|
+
|
|
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
|
+
},
|
|
933
|
+
|
|
934
|
+
// 3. Register Assets
|
|
935
|
+
adminAssets: () => ({
|
|
936
|
+
scripts: ['/api/plugins/my-plugin/setup.js'],
|
|
937
|
+
}),
|
|
938
|
+
});
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
### Plugin Lifecycle Hooks
|
|
942
|
+
|
|
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. |
|
|
951
|
+
|
|
952
|
+
### Global Admin Registry (`window.opaca`)
|
|
953
|
+
|
|
954
|
+
Plugins can interact with the Admin UI via the `window.opaca` object:
|
|
955
|
+
|
|
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.
|
|
959
|
+
|
|
960
|
+
### Registering the Plugin
|
|
961
|
+
|
|
962
|
+
Add your plugin to the `plugins` array in `opacacms.config.ts`:
|
|
963
|
+
|
|
964
|
+
```typescript
|
|
965
|
+
export default defineConfig({
|
|
966
|
+
// ...
|
|
967
|
+
plugins: [myPlugin()],
|
|
968
|
+
});
|
|
969
|
+
```
|