opacacms 0.1.20 → 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 CHANGED
@@ -1,1063 +1,227 @@
1
- # OpacaCMS
1
+ # 🚀 OpacaCMS
2
2
 
3
- > An experimental, runtime-agnostic headless CMS built with TypeScript. Define your schema, access control, and business logic directly in code no visual builders, no lock-in.
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 natively on **Node.js, Bun, Cloudflare Workers, and Next.js** using [Hono](https://hono.dev) for routing. The schema you define becomes both your database tables and your REST API, automatically.
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
- ## Table of Contents
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 (File Uploads)](#storage-file-uploads)
23
- - [Internationalization (i18n)](#internationalization-i18n)
24
- - [Custom Admin Components](#custom-admin-components)
25
- - [The Client SDK](#the-client-sdk)
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
- **Requirements:** [Bun](https://bun.sh) (recommended) or Node.js 18+.
31
+ You'll need [Bun](https://bun.sh) (highly recommended) or Node.js 18+.
35
32
 
36
33
  ```bash
37
- # Scaffold a new project
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
- To add OpacaCMS to an **existing** project:
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 + database + auth configuration
58
- ├── migrations/ ← generated migration files
59
- ├── opaca-types.d.ts generated TypeScript types (optional)
60
- ├── collections/ your collection definitions
61
- │ ├── posts.ts
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
- Everything is defined in `opacacms.config.ts` and exported as the **default export**.
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** maps directly to a database table and generates a full REST API automatically. Collections are defined using the **Collection Builder** from `opacacms/schema`.
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') // slug → /api/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').localized().required(),
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().displayField('name'),
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
- Register in your config:
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
- 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 |
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
- ```typescript
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
- ### Virtual / Computed
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
- ```
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
- ## Globals
121
+ ## 🎨 Custom Admin Components
376
122
 
377
- A **Global** is a singleton document (one row in the database). Perfect for site settings, navigation, or any app-wide configuration.
123
+ This is where OpacaCMS shines. You can replace any field UI with your own **React** or **Vue** components. 💅
378
124
 
379
- ```typescript
380
- // globals/site-settings.ts
381
- import { Global, Field } from 'opacacms/schema';
125
+ ### 1️⃣ React components
382
126
 
383
- export const siteSettings = Global.create('site-settings')
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
- // 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'),
399
- ]);
400
- ```
401
-
402
- Register in your config:
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
- ```typescript
405
- export default defineConfig({
406
- globals: [siteSettings.build(), headerSettings.build()],
407
- });
141
+ defineReactField('my-color-picker', ColorPicker);
408
142
  ```
409
143
 
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:**
144
+ ### 2️⃣ Vue components
424
145
 
425
- | Method | Endpoint | Description |
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
- ## Access Control
433
-
434
- Access can be defined at the **collection level** and at the **field level**.
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
- ```typescript
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
- The `access` function receives: `{ req, user, session, apiKey, data, operation }`.
450
-
451
- ### Field-level access
157
+ ### 3️⃣ Reference in Schema
452
158
 
453
159
  ```typescript
454
- Field.number('creditScore').access({
455
- readOnly: ({ user }) => user?.role !== 'admin',
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
- ## Authentication
169
+ ## 🔐 Access Control
522
170
 
523
- OpacaCMS uses [better-auth](https://better-auth.com) under the hood. Auth tables (`_users`, `_sessions`, etc.) are created automatically by migrations.
171
+ Secure your data with simple functions. 🛡️
524
172
 
525
173
  ```typescript
526
- export default defineConfig({
527
- auth: {
528
- strategies: {
529
- emailPassword: true,
530
- magicLink: {
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
- ## Database Adapters
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
- // Cloudflare D1
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
- ### 1. Generate migration files
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
- The generated file has zero external dependencies — no need to install `kysely`:
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
- Use the typed client to query your CMS from any frontend:
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://my-cms.example.com' });
210
+ const cms = createClient({ baseURL: 'https://api.mycms.com' });
797
211
 
798
- // List with filtering and pagination
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
- page: 1,
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
- ## Production Build
220
+ ## 🌟 Why OpacaCMS?
1034
221
 
1035
- ### Disable push mode
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
- ```typescript
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) 🎈