opacacms 0.1.19 → 0.1.21
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 +110 -946
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.js +24 -0
- package/dist/admin/vue.d.ts +17 -0
- package/dist/{chunk-qkn1ykrj.js → chunk-0sdceeys.js} +9 -26
- package/dist/{chunk-2z8wxx9g.js → chunk-59sg3pw9.js} +9 -19
- package/dist/{chunk-pxh5encs.js → chunk-61kwqve4.js} +13 -36
- package/dist/{chunk-xtwc125q.js → chunk-cpw2y3pn.js} +1 -1
- package/dist/{chunk-x2ejaftz.js → chunk-ekxkvqjm.js} +9 -19
- package/dist/{chunk-wq314kkx.js → chunk-fqastxq9.js} +9 -26
- package/dist/{chunk-b5qdy72a.js → chunk-mycmsjd9.js} +3 -2
- package/dist/{chunk-zvwb67nd.js → chunk-t9v845m2.js} +2 -2
- package/dist/{chunk-9rqm0emy.js → chunk-xrfhhz85.js} +1 -1
- package/dist/db/better-sqlite.js +2 -2
- package/dist/db/bun-sqlite.js +2 -2
- package/dist/db/d1.js +2 -2
- package/dist/db/index.js +6 -6
- package/dist/db/postgres.js +2 -2
- package/dist/db/sqlite.js +2 -2
- package/dist/index.js +2 -2
- package/dist/runtimes/bun.js +2 -2
- package/dist/runtimes/cloudflare-workers.js +2 -2
- package/dist/runtimes/next.js +2 -2
- package/dist/runtimes/node.js +2 -2
- package/dist/schema/collection.d.ts +0 -4
- package/dist/schema/global.d.ts +0 -8
- package/dist/schema/index.js +0 -8
- package/dist/server.js +3 -3
- package/dist/types.d.ts +11 -3
- package/dist/validation.d.ts +20 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,1063 +1,227 @@
|
|
|
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
|
-
- [Globals](
|
|
17
|
-
- [Access Control](
|
|
18
|
-
- [Hooks](
|
|
19
|
-
- [Authentication](
|
|
20
|
-
- [Database Adapters](
|
|
21
|
-
- [Migrations](
|
|
22
|
-
- [Storage
|
|
23
|
-
- [
|
|
24
|
-
- [Custom
|
|
25
|
-
- [
|
|
26
|
-
- [Runtime Integrations](#runtime-integrations)
|
|
27
|
-
- [CLI Reference](#cli-reference)
|
|
28
|
-
- [Production Build](#production-build)
|
|
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
|
+
- [🌍 Globals](#-globals)
|
|
17
|
+
- [🔐 Access Control](#-access-control)
|
|
18
|
+
- [⚓ Hooks](#-hooks)
|
|
19
|
+
- [👤 Authentication](#-authentication)
|
|
20
|
+
- [🗄 Database Adapters](#-database-adapters)
|
|
21
|
+
- [🔄 Migrations](#-migrations)
|
|
22
|
+
- [☁️ Storage](#-storage)
|
|
23
|
+
- [🌐 i18n](#-internationalization-i18n)
|
|
24
|
+
- [🎨 Custom Components](#-custom-admin-components)
|
|
25
|
+
- [🔌 API & SDK](#-the-client-sdk)
|
|
29
26
|
|
|
30
27
|
---
|
|
31
28
|
|
|
32
|
-
## Getting Started
|
|
29
|
+
## ✨ Getting Started
|
|
33
30
|
|
|
34
|
-
|
|
31
|
+
You'll need [Bun](https://bun.sh) (highly recommended) or Node.js 18+.
|
|
35
32
|
|
|
36
33
|
```bash
|
|
37
|
-
#
|
|
38
|
-
bunx opacacms init my-cms
|
|
34
|
+
# Kickstart a new project
|
|
35
|
+
bunx opacacms init my-awesome-cms
|
|
39
36
|
|
|
40
|
-
cd my-cms
|
|
37
|
+
cd my-awesome-cms
|
|
41
38
|
bun install
|
|
42
39
|
bun dev
|
|
43
40
|
```
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
bun add opacacms
|
|
49
|
-
```
|
|
42
|
+
Adding to an existing project? Easy: `bun add opacacms` 📦
|
|
50
43
|
|
|
51
44
|
---
|
|
52
45
|
|
|
53
|
-
## Project Structure
|
|
46
|
+
## 🏗 Project Structure
|
|
54
47
|
|
|
55
|
-
```
|
|
48
|
+
```text
|
|
56
49
|
my-cms/
|
|
57
|
-
├── opacacms.config.ts ← schema +
|
|
58
|
-
├── migrations/ ←
|
|
59
|
-
├──
|
|
60
|
-
├──
|
|
61
|
-
|
|
62
|
-
│ └── products.ts
|
|
63
|
-
├── globals/ ← your global definitions
|
|
64
|
-
│ └── site-settings.ts
|
|
65
|
-
└── src/
|
|
66
|
-
└── index.ts ← runtime entry point
|
|
50
|
+
├── opacacms.config.ts ← The heart of your CMS (schema + DB + auth)
|
|
51
|
+
├── migrations/ ← Your DB history
|
|
52
|
+
├── collections/ ← Where your data models live
|
|
53
|
+
├── globals/ ← Singleton documents (settings, etc.)
|
|
54
|
+
└── src/ ← Your app logic
|
|
67
55
|
```
|
|
68
56
|
|
|
69
57
|
---
|
|
70
58
|
|
|
71
|
-
## Configuration
|
|
59
|
+
## ⚙️ Configuration
|
|
72
60
|
|
|
73
|
-
|
|
61
|
+
Your `opacacms.config.ts` is the single source of truth. Export its configuration as the **default export**.
|
|
74
62
|
|
|
75
63
|
```typescript
|
|
76
|
-
// opacacms.config.ts
|
|
77
64
|
import { defineConfig } from 'opacacms';
|
|
78
65
|
import { createSQLiteAdapter } from 'opacacms/db/sqlite';
|
|
79
66
|
import { posts } from './collections/posts';
|
|
80
|
-
import { siteSettings } from './globals/site-settings';
|
|
81
67
|
|
|
82
68
|
export default defineConfig({
|
|
83
|
-
appName: 'My Blog',
|
|
69
|
+
appName: 'My Shiny Blog 💫',
|
|
84
70
|
serverURL: 'http://localhost:3000',
|
|
85
|
-
secret: process.env.OPACA_SECRET,
|
|
86
71
|
db: createSQLiteAdapter('local.db'),
|
|
87
|
-
collections: [posts.build()
|
|
88
|
-
globals: [siteSettings.build()],
|
|
72
|
+
collections: [posts], // No need to call .build() anymore! ✌️
|
|
89
73
|
});
|
|
90
74
|
```
|
|
91
75
|
|
|
92
|
-
> `defineConfig` validates your configuration at startup and throws a descriptive error if anything is misconfigured.
|
|
93
|
-
|
|
94
|
-
### Full config options
|
|
95
|
-
|
|
96
|
-
| Option | Type | Description |
|
|
97
|
-
| ---------------- | -------------------------------- | --------------------------------------------- |
|
|
98
|
-
| `appName` | `string` | Displayed in the admin UI |
|
|
99
|
-
| `serverURL` | `string` | The public URL of your application |
|
|
100
|
-
| `secret` | `string` | Signs auth tokens — use an env variable |
|
|
101
|
-
| `db` | `DatabaseAdapter` | Required. Your database adapter |
|
|
102
|
-
| `collections` | `Collection[]` | Your data schemas (call `.build()` on each) |
|
|
103
|
-
| `globals` | `Global[]` | Singleton documents (call `.build()` on each) |
|
|
104
|
-
| `storages` | `Record<string, StorageAdapter>` | Named file storage buckets |
|
|
105
|
-
| `auth` | `OpacaAuthConfig` | Auth strategies & features |
|
|
106
|
-
| `api` | `ApiConfig` | Rate limiting, max pagination limit |
|
|
107
|
-
| `i18n` | `{ locales, defaultLocale }` | Localization |
|
|
108
|
-
| `trustedOrigins` | `string[]` | For CORS and cookie security |
|
|
109
|
-
|
|
110
76
|
---
|
|
111
77
|
|
|
112
|
-
## Collections
|
|
78
|
+
## 📦 Collections
|
|
113
79
|
|
|
114
|
-
A **Collection**
|
|
80
|
+
A **Collection** is a database table + a REST API. Pure magic. ✨
|
|
115
81
|
|
|
116
82
|
```typescript
|
|
117
83
|
// collections/posts.ts
|
|
118
84
|
import { Collection, Field } from 'opacacms/schema';
|
|
119
85
|
|
|
120
|
-
export const posts = Collection.create('posts')
|
|
86
|
+
export const posts = Collection.create('posts')
|
|
121
87
|
.label('Blog Posts')
|
|
122
88
|
.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
89
|
.fields([
|
|
133
|
-
Field.text('title').
|
|
90
|
+
Field.text('title').required(),
|
|
134
91
|
Field.slug('slug').from('title').unique(),
|
|
135
92
|
Field.richText('content').localized(),
|
|
136
|
-
Field.relationship('author').to('_users').single()
|
|
137
|
-
Field.select('category')
|
|
138
|
-
.choices(['News', 'Tutorial', 'Review'])
|
|
139
|
-
.defaultValue('News'),
|
|
140
|
-
Field.date('publishedAt').label('Published At'),
|
|
93
|
+
Field.relationship('author').to('_users').single(),
|
|
141
94
|
]);
|
|
142
95
|
```
|
|
143
96
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
```typescript
|
|
147
|
-
// opacacms.config.ts
|
|
148
|
-
import { posts } from './collections/posts';
|
|
149
|
-
|
|
150
|
-
export default defineConfig({
|
|
151
|
-
collections: [posts.build()],
|
|
152
|
-
});
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
### Collection Builder — all methods
|
|
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
|
|
97
|
+
### 🛠 Builder Methods
|
|
174
98
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 |
|
|
184
|
-
|
|
185
|
-
**Query parameters:** `?page=1&limit=10&sort=createdAt:desc&populate=author&locale=pt-BR`
|
|
186
|
-
|
|
187
|
-
**Filtering:** `GET /api/posts?title[like]=%TypeScript%&_status[equals]=published`
|
|
99
|
+
- `.label(name)`: Title in Sidebar.
|
|
100
|
+
- `.icon(name)`: Lucide icon name.
|
|
101
|
+
- `.fields([...])`: Your data structure.
|
|
102
|
+
- `.access(rules)`: Who can do what.
|
|
103
|
+
- `.hooks(fns)`: Lifecycle side-effects.
|
|
188
104
|
|
|
189
105
|
---
|
|
190
106
|
|
|
191
|
-
## Field Types
|
|
192
|
-
|
|
193
|
-
All fields are constructed with `Field` from `opacacms/schema`. Every field starts with `Field.<type>(name)` and supports a fluent modifier chain.
|
|
194
|
-
|
|
195
|
-
### Common modifiers — available on all fields
|
|
107
|
+
## 🧪 Field Types
|
|
196
108
|
|
|
197
|
-
|
|
198
|
-
Field.text('title')
|
|
199
|
-
.label('Article Title')
|
|
200
|
-
.required() // NOT NULL
|
|
201
|
-
.unique() // UNIQUE constraint
|
|
202
|
-
.localized() // stored per-locale
|
|
203
|
-
.defaultValue('Untitled')
|
|
204
|
-
.readOnly() // non-editable in admin
|
|
205
|
-
.hidden() // hidden from admin UI
|
|
206
|
-
.placeholder('Enter title…')
|
|
207
|
-
.admin({ description: 'The article title', width: '50%' })
|
|
208
|
-
.validate((val) => val.length > 3 || 'Too short');
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
### Primitives
|
|
212
|
-
|
|
213
|
-
```typescript
|
|
214
|
-
Field.text('title').required();
|
|
215
|
-
Field.textarea('bio').placeholder('Tell us about yourself…');
|
|
216
|
-
Field.number('price').min(0).max(99999).defaultValue(0);
|
|
217
|
-
Field.boolean('active').defaultValue(true).label('Is Active?');
|
|
218
|
-
Field.date('publishedAt');
|
|
219
|
-
Field.json('metadata');
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
### Slug
|
|
223
|
-
|
|
224
|
-
```typescript
|
|
225
|
-
Field.slug('slug').from('title').unique();
|
|
226
|
-
// 'My Article' → 'my-article'
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
### Select & Radio
|
|
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
|
|
277
|
-
|
|
278
|
-
```typescript
|
|
279
|
-
// Single reference
|
|
280
|
-
Field.relationship('author').to('_users').single().displayField('name');
|
|
281
|
-
|
|
282
|
-
// hasMany — creates a join table automatically
|
|
283
|
-
Field.relationship('tags').to('tags').many();
|
|
284
|
-
|
|
285
|
-
// Type-safe reference to another Collection builder
|
|
286
|
-
import { products } from './products';
|
|
287
|
-
Field.relationship('related').to(products).many();
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
### Join (virtual inverse relationship)
|
|
291
|
-
|
|
292
|
-
```typescript
|
|
293
|
-
// Lists documents from another collection that reference this one
|
|
294
|
-
Field.join('comments').collection('comments').on('post_id');
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
### Group (nested object)
|
|
298
|
-
|
|
299
|
-
Flattened as `preferences__theme` in the database, returned as `{ preferences: { theme } }` in the API.
|
|
300
|
-
|
|
301
|
-
```typescript
|
|
302
|
-
Field.group('preferences').fields(
|
|
303
|
-
Field.select('theme').choices(['light', 'dark', 'system']),
|
|
304
|
-
Field.boolean('notifications').defaultValue(true),
|
|
305
|
-
);
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
### Blocks (dynamic content sections)
|
|
309
|
-
|
|
310
|
-
Each block type gets its own table. The API returns them as a single ordered array.
|
|
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
|
-
),
|
|
328
|
-
);
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
### Layout fields (admin only — no database column)
|
|
332
|
-
|
|
333
|
-
```typescript
|
|
334
|
-
// Side-by-side columns
|
|
335
|
-
Field.row().fields(
|
|
336
|
-
Field.text('firstName').required(),
|
|
337
|
-
Field.text('lastName').required(),
|
|
338
|
-
);
|
|
339
|
-
|
|
340
|
-
// Tabbed sections
|
|
341
|
-
Field.tabs()
|
|
342
|
-
.tab('Content', Field.text('title').required(), Field.textarea('description'))
|
|
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
|
-
);
|
|
354
|
-
```
|
|
109
|
+
We've got everything you need to build powerful schemas:
|
|
355
110
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
```
|
|
111
|
+
- `Field.text()`: Simple strings.
|
|
112
|
+
- `Field.richText()`: Block-based editor (Notion style!). 📝
|
|
113
|
+
- `Field.relationship()`: Connect your data.
|
|
114
|
+
- `Field.file()`: Simple uploads. ☁️
|
|
115
|
+
- `Field.blocks()`: Dynamic page builders. 🧱
|
|
116
|
+
- `Field.group()`: Nested objects.
|
|
117
|
+
- `Field.select()` / `Field.checkbox()`: Pickers.
|
|
372
118
|
|
|
373
119
|
---
|
|
374
120
|
|
|
375
|
-
##
|
|
121
|
+
## 🎨 Custom Admin Components
|
|
376
122
|
|
|
377
|
-
|
|
123
|
+
This is where OpacaCMS shines. You can replace any field UI with your own **React** or **Vue** components. 💅
|
|
378
124
|
|
|
379
|
-
|
|
380
|
-
// globals/site-settings.ts
|
|
381
|
-
import { Global, Field } from 'opacacms/schema';
|
|
125
|
+
### 1️⃣ React components
|
|
382
126
|
|
|
383
|
-
|
|
384
|
-
.label('Site Settings')
|
|
385
|
-
.icon('Settings')
|
|
386
|
-
.fields([
|
|
387
|
-
Field.text('siteName').required().defaultValue('My Site'),
|
|
388
|
-
Field.text('email').required(),
|
|
389
|
-
Field.text('phone'),
|
|
390
|
-
]);
|
|
127
|
+
Just use our `defineReactField` helper!
|
|
391
128
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
129
|
+
```tsx
|
|
130
|
+
// MyColorPicker.tsx
|
|
131
|
+
import { defineReactField } from 'opacacms/admin';
|
|
132
|
+
|
|
133
|
+
const ColorPicker = ({ value, onChange }) => (
|
|
134
|
+
<input
|
|
135
|
+
type="color"
|
|
136
|
+
value={value}
|
|
137
|
+
onChange={(e) => onChange(e.target.value)}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
403
140
|
|
|
404
|
-
|
|
405
|
-
export default defineConfig({
|
|
406
|
-
globals: [siteSettings.build(), headerSettings.build()],
|
|
407
|
-
});
|
|
141
|
+
defineReactField('my-color-picker', ColorPicker);
|
|
408
142
|
```
|
|
409
143
|
|
|
410
|
-
###
|
|
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:**
|
|
144
|
+
### 2️⃣ Vue components
|
|
424
145
|
|
|
425
|
-
|
|
426
|
-
| ------- | ---------------------------- | ------------------- |
|
|
427
|
-
| `GET` | `/api/globals/site-settings` | Read the document |
|
|
428
|
-
| `PATCH` | `/api/globals/site-settings` | Update the document |
|
|
146
|
+
Same thing, just pass your `createApp` function!
|
|
429
147
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
### Collection-level access
|
|
148
|
+
```tsx
|
|
149
|
+
// MyVuePicker.vue
|
|
150
|
+
import { defineVueField } from 'opacacms/admin';
|
|
151
|
+
import { createApp } from 'vue';
|
|
152
|
+
import MyVueComponent from './MyVueComponent.vue';
|
|
437
153
|
|
|
438
|
-
|
|
439
|
-
export const orders = Collection.create('orders')
|
|
440
|
-
.fields([...])
|
|
441
|
-
.access({
|
|
442
|
-
read: ({ user }) => !!user,
|
|
443
|
-
create: ({ user }) => user?.role === 'admin',
|
|
444
|
-
update: ({ user, data }) => user?.id === data?.userId,
|
|
445
|
-
delete: false,
|
|
446
|
-
});
|
|
154
|
+
defineVueField('my-vue-picker', MyVueComponent, { createApp });
|
|
447
155
|
```
|
|
448
156
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
### Field-level access
|
|
157
|
+
### 3️⃣ Reference in Schema
|
|
452
158
|
|
|
453
159
|
```typescript
|
|
454
|
-
Field.
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
Field.textarea('internalNotes').access({
|
|
459
|
-
hidden: ({ user }) => user?.role !== 'admin', // stripped from API responses
|
|
160
|
+
Field.text('color').admin({
|
|
161
|
+
components: {
|
|
162
|
+
Field: 'my-color-picker',
|
|
163
|
+
},
|
|
460
164
|
});
|
|
461
165
|
```
|
|
462
166
|
|
|
463
|
-
### Requiring API keys
|
|
464
|
-
|
|
465
|
-
```typescript
|
|
466
|
-
export const webhooks = Collection.create('webhooks')
|
|
467
|
-
.access({ requireApiKey: true })
|
|
468
|
-
.fields([...]);
|
|
469
|
-
```
|
|
470
|
-
|
|
471
|
-
---
|
|
472
|
-
|
|
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
|
-
```
|
|
504
|
-
|
|
505
|
-
### Webhooks
|
|
506
|
-
|
|
507
|
-
```typescript
|
|
508
|
-
export const products = Collection.create('products')
|
|
509
|
-
.webhooks([
|
|
510
|
-
{
|
|
511
|
-
events: ['afterCreate', 'afterUpdate', 'afterDelete'],
|
|
512
|
-
url: 'https://api.example.com/webhooks/cms',
|
|
513
|
-
headers: { 'X-Secret': process.env.WEBHOOK_SECRET! },
|
|
514
|
-
},
|
|
515
|
-
])
|
|
516
|
-
.fields([...]);
|
|
517
|
-
```
|
|
518
|
-
|
|
519
167
|
---
|
|
520
168
|
|
|
521
|
-
##
|
|
169
|
+
## 🔐 Access Control
|
|
522
170
|
|
|
523
|
-
|
|
171
|
+
Secure your data with simple functions. 🛡️
|
|
524
172
|
|
|
525
173
|
```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
|
-
},
|
|
546
|
-
},
|
|
547
|
-
});
|
|
174
|
+
.access({
|
|
175
|
+
read: ({ user }) => !!user, // Logged in? You're good.
|
|
176
|
+
create: ({ user }) => user?.role === 'admin', // Only admins please!
|
|
177
|
+
update: ({ data, user }) => data.ownerId === user.id, // Only your own stuff.
|
|
178
|
+
})
|
|
548
179
|
```
|
|
549
180
|
|
|
550
|
-
Auth endpoints are at `/api/auth/*`. The Admin UI provides a built-in login page.
|
|
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.
|
|
555
|
-
|
|
556
181
|
---
|
|
557
182
|
|
|
558
|
-
##
|
|
559
|
-
|
|
560
|
-
Install only the adapter you need:
|
|
561
|
-
|
|
562
|
-
```typescript
|
|
563
|
-
// SQLite (zero config, great for development)
|
|
564
|
-
import { createSQLiteAdapter } from 'opacacms/db/sqlite';
|
|
565
|
-
db: createSQLiteAdapter('local.db');
|
|
566
|
-
|
|
567
|
-
// SQLite via better-sqlite3 (synchronous, faster reads)
|
|
568
|
-
import { createBetterSQLiteAdapter } from 'opacacms/db/better-sqlite';
|
|
569
|
-
db: createBetterSQLiteAdapter('local.db');
|
|
570
|
-
|
|
571
|
-
// Bun's built-in SQLite
|
|
572
|
-
import { createBunSQLiteAdapter } from 'opacacms/db/bun-sqlite';
|
|
573
|
-
db: createBunSQLiteAdapter('local.db');
|
|
574
|
-
|
|
575
|
-
// PostgreSQL
|
|
576
|
-
import { createPostgresAdapter } from 'opacacms/db/postgres';
|
|
577
|
-
db: createPostgresAdapter(process.env.DATABASE_URL);
|
|
183
|
+
## 👤 Authentication
|
|
578
184
|
|
|
579
|
-
|
|
580
|
-
import { createD1Adapter } from 'opacacms/db/d1';
|
|
581
|
-
db: createD1Adapter(env.DB);
|
|
582
|
-
```
|
|
583
|
-
|
|
584
|
-
### Push mode (development only)
|
|
585
|
-
|
|
586
|
-
```typescript
|
|
587
|
-
db: createPostgresAdapter(process.env.DATABASE_URL, {
|
|
588
|
-
push: process.env.NODE_ENV !== 'production',
|
|
589
|
-
pushDestructive: false, // if true, drops stale columns/tables
|
|
590
|
-
});
|
|
591
|
-
```
|
|
185
|
+
OpacaCMS uses [better-auth](https://better-auth.com) under the hood. It handles the heavy lifting so you don't have to. 🔒
|
|
592
186
|
|
|
593
187
|
---
|
|
594
188
|
|
|
595
|
-
## Migrations
|
|
596
|
-
|
|
597
|
-
For production, use file-based migrations instead of push mode.
|
|
189
|
+
## 🔄 Migrations
|
|
598
190
|
|
|
599
|
-
|
|
191
|
+
Go from development to production safely.
|
|
600
192
|
|
|
601
193
|
```bash
|
|
194
|
+
# Create a migration
|
|
602
195
|
bunx opacacms migrate:create initial-schema
|
|
603
|
-
# → migrations/20260319120000_initial-schema.ts (Postgres/SQLite)
|
|
604
|
-
# → migrations/20260319120000_initial-schema.sql (Cloudflare D1)
|
|
605
|
-
```
|
|
606
196
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
```typescript
|
|
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
|
-
}
|
|
627
|
-
|
|
628
|
-
export async function down(db: OpacaMigrationDb): Promise<void> {
|
|
629
|
-
await db.schema.dropTable('posts').execute();
|
|
630
|
-
}
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
### 2. Apply migrations
|
|
634
|
-
|
|
635
|
-
```bash
|
|
197
|
+
# Apply it
|
|
636
198
|
bunx opacacms migrate
|
|
637
199
|
```
|
|
638
200
|
|
|
639
|
-
### 3. Check status
|
|
640
|
-
|
|
641
|
-
```bash
|
|
642
|
-
bunx opacacms migrate:status
|
|
643
|
-
```
|
|
644
|
-
|
|
645
|
-
### Cloudflare D1
|
|
646
|
-
|
|
647
|
-
```bash
|
|
648
|
-
# Apply locally (dev)
|
|
649
|
-
bunx opacacms migrate:d1 --local --binding DB
|
|
650
|
-
|
|
651
|
-
# Apply to production
|
|
652
|
-
bunx opacacms migrate:d1 --remote --binding DB
|
|
653
|
-
```
|
|
654
|
-
|
|
655
|
-
---
|
|
656
|
-
|
|
657
|
-
## Storage (File Uploads)
|
|
658
|
-
|
|
659
|
-
Define storage buckets in your config. Reference them by name in `Field.file()`.
|
|
660
|
-
|
|
661
|
-
### S3 / AWS
|
|
662
|
-
|
|
663
|
-
```typescript
|
|
664
|
-
import { createS3Adapter } from 'opacacms/storage/s3';
|
|
665
|
-
|
|
666
|
-
storages: {
|
|
667
|
-
default: createS3Adapter({
|
|
668
|
-
region: process.env.AWS_REGION!,
|
|
669
|
-
bucket: process.env.S3_BUCKET!,
|
|
670
|
-
credentials: {
|
|
671
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
672
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
673
|
-
},
|
|
674
|
-
publicUrl: 'https://cdn.example.com',
|
|
675
|
-
}),
|
|
676
|
-
}
|
|
677
|
-
```
|
|
678
|
-
|
|
679
|
-
### Cloudflare R2
|
|
680
|
-
|
|
681
|
-
```typescript
|
|
682
|
-
import { createR2Adapter } from 'opacacms/storage/r2';
|
|
683
|
-
|
|
684
|
-
storages: {
|
|
685
|
-
default: createR2Adapter({
|
|
686
|
-
bucketBinding: env.BUCKET,
|
|
687
|
-
publicUrl: 'https://assets.example.com',
|
|
688
|
-
}),
|
|
689
|
-
secure: createR2Adapter({
|
|
690
|
-
bucketBinding: env.SECURE_BUCKET,
|
|
691
|
-
publicUrl: 'https://assets.example.com',
|
|
692
|
-
}),
|
|
693
|
-
}
|
|
694
|
-
```
|
|
695
|
-
|
|
696
|
-
### Local filesystem (development)
|
|
697
|
-
|
|
698
|
-
```typescript
|
|
699
|
-
import { createLocalAdapter } from 'opacacms/storage/local';
|
|
700
|
-
|
|
701
|
-
storages: {
|
|
702
|
-
default: createLocalAdapter({
|
|
703
|
-
directory: './public/uploads',
|
|
704
|
-
publicUrl: 'http://localhost:3000/uploads',
|
|
705
|
-
}),
|
|
706
|
-
}
|
|
707
|
-
```
|
|
708
|
-
|
|
709
|
-
---
|
|
710
|
-
|
|
711
|
-
## Internationalization (i18n)
|
|
712
|
-
|
|
713
|
-
```typescript
|
|
714
|
-
export default defineConfig({
|
|
715
|
-
i18n: {
|
|
716
|
-
locales: ['en', 'pt-BR', 'es'],
|
|
717
|
-
defaultLocale: 'pt-BR',
|
|
718
|
-
},
|
|
719
|
-
});
|
|
720
|
-
```
|
|
721
|
-
|
|
722
|
-
Mark individual fields as localized with `.localized()`:
|
|
723
|
-
|
|
724
|
-
```typescript
|
|
725
|
-
Field.text('title').localized().required();
|
|
726
|
-
Field.richText('body').localized();
|
|
727
|
-
Field.text('slug'); // not localized — same value across all locales
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
The API resolves the locale from the `x-opaca-locale` header or `?locale=` query param, falling back to `defaultLocale`. Pass `?locale=all` to get all locales at once.
|
|
731
|
-
|
|
732
|
-
---
|
|
733
|
-
|
|
734
|
-
## Custom Admin Components
|
|
735
|
-
|
|
736
|
-
You can replace any field's input or table cell with your own component. This works with **any framework** — React, Vue, Vanilla JS — by implementing the `defineCustomField` adapter.
|
|
737
|
-
|
|
738
|
-
### 1. Register a custom field tag
|
|
739
|
-
|
|
740
|
-
```typescript
|
|
741
|
-
// src/admin/my-components.ts
|
|
742
|
-
import { defineCustomField } from 'opacacms/admin';
|
|
743
|
-
import { createRoot } from 'react-dom/client';
|
|
744
|
-
import MyColorPicker from './MyColorPicker';
|
|
745
|
-
|
|
746
|
-
defineCustomField('my-color-picker', {
|
|
747
|
-
mount(container, props) {
|
|
748
|
-
(container as any)._root = createRoot(container);
|
|
749
|
-
(container as any)._root.render(<MyColorPicker {...props} />);
|
|
750
|
-
},
|
|
751
|
-
update(container, props) {
|
|
752
|
-
(container as any)._root.render(<MyColorPicker {...props} />);
|
|
753
|
-
},
|
|
754
|
-
unmount(container) {
|
|
755
|
-
(container as any)._root.unmount();
|
|
756
|
-
},
|
|
757
|
-
});
|
|
758
|
-
```
|
|
759
|
-
|
|
760
|
-
The `props` object your component receives:
|
|
761
|
-
|
|
762
|
-
| Prop | Type | Description |
|
|
763
|
-
| ------------- | --------------- | ------------------------------------------- |
|
|
764
|
-
| `value` | `any` | Current field value |
|
|
765
|
-
| `onChange` | `(val) => void` | Call to update the value |
|
|
766
|
-
| `fieldConfig` | `object` | The field's schema definition |
|
|
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 |
|
|
771
|
-
|
|
772
|
-
### 2. Reference in the schema
|
|
773
|
-
|
|
774
|
-
```typescript
|
|
775
|
-
Field.text('primaryColor').admin({
|
|
776
|
-
components: {
|
|
777
|
-
Field: 'my-color-picker', // used in the edit form
|
|
778
|
-
Cell: 'my-color-cell', // used in the collection list table
|
|
779
|
-
},
|
|
780
|
-
});
|
|
781
|
-
```
|
|
782
|
-
|
|
783
|
-
### 3. Load your script
|
|
784
|
-
|
|
785
|
-
Your custom component script must be loaded in the browser before the admin UI mounts. For Cloudflare Workers, serve the file as a static asset and include it as a `<script>` tag in the admin HTML page. For Next.js, import it in your admin layout.
|
|
786
|
-
|
|
787
201
|
---
|
|
788
202
|
|
|
789
|
-
## The Client SDK
|
|
203
|
+
## 🔌 The Client SDK
|
|
790
204
|
|
|
791
|
-
|
|
205
|
+
Query your CMS like a pro with full type-safety. ⚡️
|
|
792
206
|
|
|
793
207
|
```typescript
|
|
794
208
|
import { createClient } from 'opacacms/client';
|
|
795
209
|
|
|
796
|
-
const cms = createClient({ baseURL: 'https://
|
|
210
|
+
const cms = createClient({ baseURL: 'https://api.mycms.com' });
|
|
797
211
|
|
|
798
|
-
|
|
799
|
-
const result = await cms.collections.posts.find({
|
|
800
|
-
'_status[equals]': 'published',
|
|
212
|
+
const posts = await cms.collections.posts.find({
|
|
801
213
|
limit: 10,
|
|
802
|
-
|
|
803
|
-
});
|
|
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
|
-
```
|
|
819
|
-
|
|
820
|
-
### Typed client with generated types
|
|
821
|
-
|
|
822
|
-
```bash
|
|
823
|
-
bunx opacacms generate:types --url http://localhost:3000 --out opaca-types.d.ts
|
|
824
|
-
```
|
|
825
|
-
|
|
826
|
-
```typescript
|
|
827
|
-
import { createClient } from 'opacacms/client';
|
|
828
|
-
import type { GeneratedTypes } from './opaca-types';
|
|
829
|
-
|
|
830
|
-
const cms = createClient<GeneratedTypes>({ baseURL: '...' });
|
|
831
|
-
|
|
832
|
-
// IDE autocomplete now knows the exact shape of every collection
|
|
833
|
-
const post = await cms.collections.posts.findOne('123');
|
|
834
|
-
// post.title → string ✓
|
|
835
|
-
```
|
|
836
|
-
|
|
837
|
-
---
|
|
838
|
-
|
|
839
|
-
## Runtime Integrations
|
|
840
|
-
|
|
841
|
-
### Next.js
|
|
842
|
-
|
|
843
|
-
Create a catch-all API route:
|
|
844
|
-
|
|
845
|
-
```
|
|
846
|
-
src/app/api/[[...route]]/route.ts
|
|
847
|
-
```
|
|
848
|
-
|
|
849
|
-
```typescript
|
|
850
|
-
// src/app/api/[[...route]]/route.ts
|
|
851
|
-
import { createNextHandler } from 'opacacms/runtimes/next';
|
|
852
|
-
import config from '../../../opacacms.config';
|
|
853
|
-
|
|
854
|
-
export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } =
|
|
855
|
-
createNextHandler(config);
|
|
856
|
-
```
|
|
857
|
-
|
|
858
|
-
```typescript
|
|
859
|
-
// opacacms.config.ts
|
|
860
|
-
import { defineConfig } from 'opacacms';
|
|
861
|
-
import { createPostgresAdapter } from 'opacacms/db/postgres';
|
|
862
|
-
|
|
863
|
-
export default defineConfig({
|
|
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:
|
|
875
|
-
|
|
876
|
-
```tsx
|
|
877
|
-
// src/app/admin/[[...all]]/page.tsx
|
|
878
|
-
export default function AdminPage() {
|
|
879
|
-
return (
|
|
880
|
-
<html>
|
|
881
|
-
<body>
|
|
882
|
-
<opaca-admin server-url={process.env.NEXT_PUBLIC_API_URL} />
|
|
883
|
-
<script type="module" src="/opacacms/webcomponent.js" />
|
|
884
|
-
</body>
|
|
885
|
-
</html>
|
|
886
|
-
);
|
|
887
|
-
}
|
|
888
|
-
```
|
|
889
|
-
|
|
890
|
-
---
|
|
891
|
-
|
|
892
|
-
### Cloudflare Workers
|
|
893
|
-
|
|
894
|
-
```typescript
|
|
895
|
-
// src/index.ts
|
|
896
|
-
import { createCloudflareWorkersHandler } from 'opacacms/runtimes/cloudflare-workers';
|
|
897
|
-
import getConfig from './opacacms.config';
|
|
898
|
-
|
|
899
|
-
const app = createCloudflareWorkersHandler(getConfig);
|
|
900
|
-
|
|
901
|
-
// Serve the Admin UI for /admin/* routes
|
|
902
|
-
app.get('/admin*', (c) => {
|
|
903
|
-
return c.html(`
|
|
904
|
-
<!DOCTYPE html>
|
|
905
|
-
<html>
|
|
906
|
-
<head>
|
|
907
|
-
<title>Admin</title>
|
|
908
|
-
<link rel="stylesheet" href="/admin.css">
|
|
909
|
-
</head>
|
|
910
|
-
<body>
|
|
911
|
-
<opaca-admin server-url="${new URL(c.req.url).origin}"></opaca-admin>
|
|
912
|
-
<script type="module" src="/webcomponent.js"></script>
|
|
913
|
-
</body>
|
|
914
|
-
</html>
|
|
915
|
-
`);
|
|
214
|
+
sort: 'createdAt:desc',
|
|
916
215
|
});
|
|
917
|
-
|
|
918
|
-
export default app;
|
|
919
|
-
```
|
|
920
|
-
|
|
921
|
-
```typescript
|
|
922
|
-
// opacacms.config.ts
|
|
923
|
-
import { defineConfig } from 'opacacms';
|
|
924
|
-
import { createD1Adapter } from 'opacacms/db/d1';
|
|
925
|
-
import { createR2Adapter } from 'opacacms/storage/r2';
|
|
926
|
-
|
|
927
|
-
// Export as a function to access per-request Cloudflare bindings
|
|
928
|
-
const getConfig = (env: Env, request: Request) =>
|
|
929
|
-
defineConfig({
|
|
930
|
-
appName: 'My Workers CMS',
|
|
931
|
-
serverURL: new URL(request.url).origin,
|
|
932
|
-
secret: env.OPACA_SECRET,
|
|
933
|
-
trustedOrigins: ['https://my-frontend.com'],
|
|
934
|
-
db: createD1Adapter(env.DB),
|
|
935
|
-
storages: {
|
|
936
|
-
default: createR2Adapter({
|
|
937
|
-
bucketBinding: env.BUCKET,
|
|
938
|
-
publicUrl: new URL(request.url).origin,
|
|
939
|
-
}),
|
|
940
|
-
},
|
|
941
|
-
auth: {
|
|
942
|
-
features: { apiKeys: { enabled: true } },
|
|
943
|
-
},
|
|
944
|
-
i18n: {
|
|
945
|
-
locales: ['en', 'pt-BR'],
|
|
946
|
-
defaultLocale: 'pt-BR',
|
|
947
|
-
},
|
|
948
|
-
collections: [
|
|
949
|
-
/* posts.build(), ... */
|
|
950
|
-
],
|
|
951
|
-
globals: [
|
|
952
|
-
/* siteSettings.build(), ... */
|
|
953
|
-
],
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
export default getConfig;
|
|
957
|
-
```
|
|
958
|
-
|
|
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
|
-
---
|
|
972
|
-
|
|
973
|
-
### Bun (standalone)
|
|
974
|
-
|
|
975
|
-
```typescript
|
|
976
|
-
// src/index.ts
|
|
977
|
-
import { createBunHandler } from 'opacacms/runtimes/bun';
|
|
978
|
-
import config from './opacacms.config';
|
|
979
|
-
|
|
980
|
-
const { init } = createBunHandler(config, { port: 3000 });
|
|
981
|
-
await init();
|
|
982
|
-
```
|
|
983
|
-
|
|
984
|
-
---
|
|
985
|
-
|
|
986
|
-
### Node.js
|
|
987
|
-
|
|
988
|
-
```typescript
|
|
989
|
-
// src/index.ts
|
|
990
|
-
import { createNodeHandler } from 'opacacms/runtimes/node';
|
|
991
|
-
import config from './opacacms.config';
|
|
992
|
-
|
|
993
|
-
const { init } = createNodeHandler(config, { port: 3000 });
|
|
994
|
-
await init();
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
---
|
|
998
|
-
|
|
999
|
-
## CLI Reference
|
|
1000
|
-
|
|
1001
|
-
The CLI auto-detects your config at `opacacms.config.ts`, `src/opacacms.config.ts`, or `index.ts`.
|
|
1002
|
-
|
|
1003
|
-
```bash
|
|
1004
|
-
# Scaffold a new project
|
|
1005
|
-
bunx opacacms init my-project
|
|
1006
|
-
|
|
1007
|
-
# Generate migrations from your current schema
|
|
1008
|
-
bunx opacacms migrate:create <name>
|
|
1009
|
-
|
|
1010
|
-
# Apply pending migrations (Postgres / SQLite)
|
|
1011
|
-
bunx opacacms migrate
|
|
1012
|
-
|
|
1013
|
-
# Check which migrations have been applied
|
|
1014
|
-
bunx opacacms migrate:status
|
|
1015
|
-
|
|
1016
|
-
# Apply SQL migrations to Cloudflare D1
|
|
1017
|
-
bunx opacacms migrate:d1 --local # local dev
|
|
1018
|
-
bunx opacacms migrate:d1 --remote # production
|
|
1019
|
-
|
|
1020
|
-
# Seed the database with fake data
|
|
1021
|
-
bunx opacacms seed --count 50
|
|
1022
|
-
bunx opacacms seed:assets --count 20
|
|
1023
|
-
|
|
1024
|
-
# Generate TypeScript types from a running instance
|
|
1025
|
-
bunx opacacms generate:types --url http://localhost:3000 --out opaca-types.d.ts
|
|
1026
|
-
|
|
1027
|
-
# Use a custom config file path
|
|
1028
|
-
bunx opacacms migrate:create --config ./path/to/config.ts
|
|
1029
216
|
```
|
|
1030
217
|
|
|
1031
218
|
---
|
|
1032
219
|
|
|
1033
|
-
##
|
|
220
|
+
## 🌟 Why OpacaCMS?
|
|
1034
221
|
|
|
1035
|
-
|
|
222
|
+
- **Blazing Fast**: Built on Hono & Bun. 🚀
|
|
223
|
+
- **Truly Decoupled**: Your data is yours. No hidden SaaS lock-in.
|
|
224
|
+
- **Developer First**: Everything is a typed API. 👩💻
|
|
225
|
+
- **Deploy Anywhere**: Vercel, Cloudflare, Fly.io, or your own VPS.
|
|
1036
226
|
|
|
1037
|
-
|
|
1038
|
-
db: createPostgresAdapter(process.env.DATABASE_URL, {
|
|
1039
|
-
push: false, // never auto-alter schema in production
|
|
1040
|
-
});
|
|
1041
|
-
```
|
|
1042
|
-
|
|
1043
|
-
### Required environment variables
|
|
1044
|
-
|
|
1045
|
-
```bash
|
|
1046
|
-
OPACA_SECRET=<random-32-char-string> # required
|
|
1047
|
-
DATABASE_URL=postgres://... # for Postgres
|
|
1048
|
-
```
|
|
1049
|
-
|
|
1050
|
-
### Deploy flow
|
|
1051
|
-
|
|
1052
|
-
```bash
|
|
1053
|
-
# Generate + apply migrations
|
|
1054
|
-
bunx opacacms migrate:create latest
|
|
1055
|
-
bunx opacacms migrate # Postgres / SQLite
|
|
1056
|
-
# or
|
|
1057
|
-
bunx opacacms migrate:d1 --remote # Cloudflare D1
|
|
1058
|
-
|
|
1059
|
-
# Start the server
|
|
1060
|
-
node dist/index.js
|
|
1061
|
-
# or
|
|
1062
|
-
wrangler deploy
|
|
1063
|
-
```
|
|
227
|
+
Ready to build something awesome? [Let's go!](https://opacacms.com) 🎈
|