opacacms 0.1.21 → 0.2.0

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