opacacms 0.1.20 → 0.2.0

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