opacacms 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1063 @@
1
+ # OpacaCMS
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.
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.
6
+
7
+ ---
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)
29
+
30
+ ---
31
+
32
+ ## Getting Started
33
+
34
+ **Requirements:** [Bun](https://bun.sh) (recommended) or Node.js 18+.
35
+
36
+ ```bash
37
+ # Scaffold a new project
38
+ bunx opacacms init my-cms
39
+
40
+ cd my-cms
41
+ bun install
42
+ bun dev
43
+ ```
44
+
45
+ To add OpacaCMS to an **existing** project:
46
+
47
+ ```bash
48
+ bun add opacacms
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Project Structure
54
+
55
+ ```
56
+ my-cms/
57
+ ├── opacacms.config.ts ← schema + database + auth configuration
58
+ ├── migrations/ ← generated migration files
59
+ ├── opaca-types.d.ts ← generated TypeScript types (optional)
60
+ ├── collections/ ← your collection definitions
61
+ │ ├── posts.ts
62
+ │ └── products.ts
63
+ ├── globals/ ← your global definitions
64
+ │ └── site-settings.ts
65
+ └── src/
66
+ └── index.ts ← runtime entry point
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Configuration
72
+
73
+ Everything is defined in `opacacms.config.ts` and exported as the **default export**.
74
+
75
+ ```typescript
76
+ // opacacms.config.ts
77
+ import { defineConfig } from 'opacacms';
78
+ import { createSQLiteAdapter } from 'opacacms/db/sqlite';
79
+ import { posts } from './collections/posts';
80
+ import { siteSettings } from './globals/site-settings';
81
+
82
+ export default defineConfig({
83
+ appName: 'My Blog',
84
+ serverURL: 'http://localhost:3000',
85
+ secret: process.env.OPACA_SECRET,
86
+ db: createSQLiteAdapter('local.db'),
87
+ collections: [posts.build()],
88
+ globals: [siteSettings.build()],
89
+ });
90
+ ```
91
+
92
+ > `defineConfig` validates your configuration at startup and throws a descriptive error if anything is misconfigured.
93
+
94
+ ### Full config options
95
+
96
+ | Option | Type | Description |
97
+ | ---------------- | -------------------------------- | --------------------------------------------- |
98
+ | `appName` | `string` | Displayed in the admin UI |
99
+ | `serverURL` | `string` | The public URL of your application |
100
+ | `secret` | `string` | Signs auth tokens — use an env variable |
101
+ | `db` | `DatabaseAdapter` | Required. Your database adapter |
102
+ | `collections` | `Collection[]` | Your data schemas (call `.build()` on each) |
103
+ | `globals` | `Global[]` | Singleton documents (call `.build()` on each) |
104
+ | `storages` | `Record<string, StorageAdapter>` | Named file storage buckets |
105
+ | `auth` | `OpacaAuthConfig` | Auth strategies & features |
106
+ | `api` | `ApiConfig` | Rate limiting, max pagination limit |
107
+ | `i18n` | `{ locales, defaultLocale }` | Localization |
108
+ | `trustedOrigins` | `string[]` | For CORS and cookie security |
109
+
110
+ ---
111
+
112
+ ## Collections
113
+
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`.
115
+
116
+ ```typescript
117
+ // collections/posts.ts
118
+ import { Collection, Field } from 'opacacms/schema';
119
+
120
+ export const posts = Collection.create('posts') // slug → /api/posts
121
+ .label('Blog Posts')
122
+ .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
+ .fields([
133
+ Field.text('title').localized().required(),
134
+ Field.slug('slug').from('title').unique(),
135
+ 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
+ });
153
+ ```
154
+
155
+ ### Collection Builder — all methods
156
+
157
+ | Method | Description |
158
+ | ------------------------- | ----------------------------------------------- |
159
+ | `Collection.create(slug)` | Start a new collection |
160
+ | `.label(string)` | Display name in the admin UI |
161
+ | `.icon(IconName)` | Lucide icon for the sidebar |
162
+ | `.useAsTitle(fieldName)` | Which field acts as the document title |
163
+ | `.timestamps(bool)` | Add `createdAt` / `updatedAt` (default: `true`) |
164
+ | `.fields([...])` | Define fields — see [Field Types](#field-types) |
165
+ | `.access(rules)` | Collection-level access control |
166
+ | `.versions(opts)` | Enable drafts & revisions |
167
+ | `.webhooks([...])` | Outbound HTTP callbacks on lifecycle events |
168
+ | `.admin(opts)` | Admin UI settings (columns, views, etc.) |
169
+ | `.virtual(name, opts)` | Add a computed field (not stored in DB) |
170
+ | `.computed(name, opts)` | Alias for `.virtual()` |
171
+ | `.build()` | Compile to the raw config object |
172
+
173
+ ### Auto-generated API
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 |
184
+
185
+ **Query parameters:** `?page=1&limit=10&sort=createdAt:desc&populate=author&locale=pt-BR`
186
+
187
+ **Filtering:** `GET /api/posts?title[like]=%TypeScript%&_status[equals]=published`
188
+
189
+ ---
190
+
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'
227
+ ```
228
+
229
+ ### Select & Radio
230
+
231
+ ```typescript
232
+ Field.select('status').choices(['draft', 'published', 'archived']);
233
+
234
+ Field.select('role').choices([
235
+ { label: 'Admin', value: 'admin' },
236
+ { label: 'Editor', value: 'editor' },
237
+ { label: 'Viewer', value: 'viewer' },
238
+ ]);
239
+
240
+ Field.radio('tier').choices([
241
+ { label: 'Free', value: 'free' },
242
+ { label: 'Pro', value: 'pro' },
243
+ { label: 'Enterprise', value: 'enterprise' },
244
+ ]);
245
+ ```
246
+
247
+ ### Rich Text
248
+
249
+ ```typescript
250
+ Field.richText('body').localized();
251
+ // defaultMode: 'notion' (block-based) | 'simple' (toolbar)
252
+ ```
253
+
254
+ ### File Upload
255
+
256
+ ```typescript
257
+ Field.file('avatar')
258
+ .bucket('default') // key in config.storages
259
+ .allowedmime_types(['image/jpeg', 'image/png', 'image/webp'])
260
+ .maxSize(5 * 1024 * 1024); // 5 MB
261
+ ```
262
+
263
+ The uploaded file URL is stored inline in the document:
264
+
265
+ ```json
266
+ {
267
+ "avatar": {
268
+ "url": "https://cdn.example.com/photo.jpg",
269
+ "filename": "photo.jpg",
270
+ "mime_type": "image/jpeg",
271
+ "filesize": 204800
272
+ }
273
+ }
274
+ ```
275
+
276
+ ### Relationship
277
+
278
+ ```typescript
279
+ // Single reference
280
+ Field.relationship('author').to('_users').single().displayField('name');
281
+
282
+ // hasMany — creates a join table automatically
283
+ Field.relationship('tags').to('tags').many();
284
+
285
+ // Type-safe reference to another Collection builder
286
+ import { products } from './products';
287
+ Field.relationship('related').to(products).many();
288
+ ```
289
+
290
+ ### Join (virtual inverse relationship)
291
+
292
+ ```typescript
293
+ // Lists documents from another collection that reference this one
294
+ Field.join('comments').collection('comments').on('post_id');
295
+ ```
296
+
297
+ ### Group (nested object)
298
+
299
+ Flattened as `preferences__theme` in the database, returned as `{ preferences: { theme } }` in the API.
300
+
301
+ ```typescript
302
+ Field.group('preferences').fields(
303
+ Field.select('theme').choices(['light', 'dark', 'system']),
304
+ Field.boolean('notifications').defaultValue(true),
305
+ );
306
+ ```
307
+
308
+ ### Blocks (dynamic content sections)
309
+
310
+ Each block type gets its own table. The API returns them as a single ordered array.
311
+
312
+ ```typescript
313
+ Field.blocks('content').blocks(
314
+ Field.block('hero')
315
+ .label('Hero Section')
316
+ .fields(
317
+ Field.text('headline').required(),
318
+ Field.text('subheadline'),
319
+ Field.text('ctaLabel'),
320
+ Field.text('ctaLink'),
321
+ ),
322
+ Field.block('featureGrid')
323
+ .label('Feature Grid')
324
+ .fields(
325
+ Field.text('sectionTitle'),
326
+ Field.number('columns').defaultValue(3),
327
+ ),
328
+ );
329
+ ```
330
+
331
+ ### Layout fields (admin only — no database column)
332
+
333
+ ```typescript
334
+ // Side-by-side columns
335
+ Field.row().fields(
336
+ Field.text('firstName').required(),
337
+ Field.text('lastName').required(),
338
+ );
339
+
340
+ // Tabbed sections
341
+ Field.tabs()
342
+ .tab('Content', Field.text('title').required(), Field.textarea('description'))
343
+ .tab('SEO', Field.text('metaTitle'), Field.textarea('metaDescription'));
344
+
345
+ // Collapsible section
346
+ Field.collapsible()
347
+ .label('SEO Metadata')
348
+ .initiallyCollapsed()
349
+ .fields(
350
+ Field.text('metaTitle'),
351
+ Field.textarea('metaDescription'),
352
+ Field.text('canonicalUrl'),
353
+ );
354
+ ```
355
+
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
+ ```
372
+
373
+ ---
374
+
375
+ ## Globals
376
+
377
+ A **Global** is a singleton document (one row in the database). Perfect for site settings, navigation, or any app-wide configuration.
378
+
379
+ ```typescript
380
+ // globals/site-settings.ts
381
+ import { Global, Field } from 'opacacms/schema';
382
+
383
+ export const siteSettings = Global.create('site-settings')
384
+ .label('Site Settings')
385
+ .icon('Settings')
386
+ .fields([
387
+ Field.text('siteName').required().defaultValue('My Site'),
388
+ Field.text('email').required(),
389
+ Field.text('phone'),
390
+ ]);
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'),
399
+ ]);
400
+ ```
401
+
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 |
429
+
430
+ ---
431
+
432
+ ## Access Control
433
+
434
+ Access can be defined at the **collection level** and at the **field level**.
435
+
436
+ ### Collection-level access
437
+
438
+ ```typescript
439
+ export const orders = Collection.create('orders')
440
+ .fields([...])
441
+ .access({
442
+ read: ({ user }) => !!user,
443
+ create: ({ user }) => user?.role === 'admin',
444
+ update: ({ user, data }) => user?.id === data?.userId,
445
+ delete: false,
446
+ });
447
+ ```
448
+
449
+ The `access` function receives: `{ req, user, session, apiKey, data, operation }`.
450
+
451
+ ### Field-level access
452
+
453
+ ```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
460
+ });
461
+ ```
462
+
463
+ ### Requiring API keys
464
+
465
+ ```typescript
466
+ export const webhooks = Collection.create('webhooks')
467
+ .access({ requireApiKey: true })
468
+ .fields([...]);
469
+ ```
470
+
471
+ ---
472
+
473
+ ## Hooks
474
+
475
+ Run async logic during a document's lifecycle. Hooks can transform data before it is written or trigger side effects after.
476
+
477
+ ```typescript
478
+ export const posts = Collection.create('posts')
479
+ .fields([
480
+ Field.text('title').required(),
481
+ Field.slug('slug').from('title').unique(),
482
+ Field.number('views'),
483
+ ])
484
+ .hooks({
485
+ beforeCreate: async (data) => {
486
+ data.views = 0;
487
+ return data;
488
+ },
489
+ afterCreate: async (doc) => {
490
+ await notifySlack(`New post: ${doc.title}`);
491
+ },
492
+ beforeUpdate: async (data) => data,
493
+ afterUpdate: async (doc) => {
494
+ /* ... */
495
+ },
496
+ beforeDelete: async (id) => {
497
+ /* ... */
498
+ },
499
+ afterDelete: async (id) => {
500
+ /* ... */
501
+ },
502
+ });
503
+ ```
504
+
505
+ ### Webhooks
506
+
507
+ ```typescript
508
+ export const products = Collection.create('products')
509
+ .webhooks([
510
+ {
511
+ events: ['afterCreate', 'afterUpdate', 'afterDelete'],
512
+ url: 'https://api.example.com/webhooks/cms',
513
+ headers: { 'X-Secret': process.env.WEBHOOK_SECRET! },
514
+ },
515
+ ])
516
+ .fields([...]);
517
+ ```
518
+
519
+ ---
520
+
521
+ ## Authentication
522
+
523
+ OpacaCMS uses [better-auth](https://better-auth.com) under the hood. Auth tables (`_users`, `_sessions`, etc.) are created automatically by migrations.
524
+
525
+ ```typescript
526
+ export default defineConfig({
527
+ auth: {
528
+ strategies: {
529
+ emailPassword: true,
530
+ magicLink: {
531
+ enabled: true,
532
+ sendEmail: async ({ email, url }) => {
533
+ await sendTransactionalEmail({ to: email, loginUrl: url });
534
+ },
535
+ },
536
+ },
537
+ socialProviders: {
538
+ github: {
539
+ clientId: process.env.GITHUB_CLIENT_ID!,
540
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
541
+ },
542
+ },
543
+ features: {
544
+ apiKeys: { enabled: true },
545
+ },
546
+ },
547
+ });
548
+ ```
549
+
550
+ Auth endpoints are at `/api/auth/*`. The Admin UI provides a built-in login page.
551
+
552
+ ### API Keys
553
+
554
+ When `apiKeys` is enabled, clients can authenticate using `Authorization: Bearer <key>` instead of cookies — useful for server-to-server calls and webhooks.
555
+
556
+ ---
557
+
558
+ ## Database Adapters
559
+
560
+ Install only the adapter you need:
561
+
562
+ ```typescript
563
+ // SQLite (zero config, great for development)
564
+ import { createSQLiteAdapter } from 'opacacms/db/sqlite';
565
+ db: createSQLiteAdapter('local.db');
566
+
567
+ // SQLite via better-sqlite3 (synchronous, faster reads)
568
+ import { createBetterSQLiteAdapter } from 'opacacms/db/better-sqlite';
569
+ db: createBetterSQLiteAdapter('local.db');
570
+
571
+ // Bun's built-in SQLite
572
+ import { createBunSQLiteAdapter } from 'opacacms/db/bun-sqlite';
573
+ db: createBunSQLiteAdapter('local.db');
574
+
575
+ // PostgreSQL
576
+ import { createPostgresAdapter } from 'opacacms/db/postgres';
577
+ db: createPostgresAdapter(process.env.DATABASE_URL);
578
+
579
+ // Cloudflare D1
580
+ import { createD1Adapter } from 'opacacms/db/d1';
581
+ db: createD1Adapter(env.DB);
582
+ ```
583
+
584
+ ### Push mode (development only)
585
+
586
+ ```typescript
587
+ db: createPostgresAdapter(process.env.DATABASE_URL, {
588
+ push: process.env.NODE_ENV !== 'production',
589
+ pushDestructive: false, // if true, drops stale columns/tables
590
+ });
591
+ ```
592
+
593
+ ---
594
+
595
+ ## Migrations
596
+
597
+ For production, use file-based migrations instead of push mode.
598
+
599
+ ### 1. Generate migration files
600
+
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)
605
+ ```
606
+
607
+ The generated file has zero external dependencies — no need to install `kysely`:
608
+
609
+ ```typescript
610
+ // migrations/20260319120000_initial-schema.ts
611
+ import type { OpacaMigrationDb } from 'opacacms/db';
612
+
613
+ export async function up(db: OpacaMigrationDb): Promise<void> {
614
+ await db.schema
615
+ .createTable('posts')
616
+ .addColumn('id', 'text', (col) => col.primaryKey())
617
+ .addColumn('title', 'text', (col) => col.notNull())
618
+ .addColumn('slug', 'text', (col) => col.unique())
619
+ .addColumn('created_at', 'text', (col) =>
620
+ col.defaultTo('CURRENT_TIMESTAMP'),
621
+ )
622
+ .addColumn('updated_at', 'text', (col) =>
623
+ col.defaultTo('CURRENT_TIMESTAMP'),
624
+ )
625
+ .execute();
626
+ }
627
+
628
+ export async function down(db: OpacaMigrationDb): Promise<void> {
629
+ await db.schema.dropTable('posts').execute();
630
+ }
631
+ ```
632
+
633
+ ### 2. Apply migrations
634
+
635
+ ```bash
636
+ bunx opacacms migrate
637
+ ```
638
+
639
+ ### 3. Check status
640
+
641
+ ```bash
642
+ bunx opacacms migrate:status
643
+ ```
644
+
645
+ ### Cloudflare D1
646
+
647
+ ```bash
648
+ # Apply locally (dev)
649
+ bunx opacacms migrate:d1 --local --binding DB
650
+
651
+ # Apply to production
652
+ bunx opacacms migrate:d1 --remote --binding DB
653
+ ```
654
+
655
+ ---
656
+
657
+ ## Storage (File Uploads)
658
+
659
+ Define storage buckets in your config. Reference them by name in `Field.file()`.
660
+
661
+ ### S3 / AWS
662
+
663
+ ```typescript
664
+ import { createS3Adapter } from 'opacacms/storage/s3';
665
+
666
+ storages: {
667
+ default: createS3Adapter({
668
+ region: process.env.AWS_REGION!,
669
+ bucket: process.env.S3_BUCKET!,
670
+ credentials: {
671
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
672
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
673
+ },
674
+ publicUrl: 'https://cdn.example.com',
675
+ }),
676
+ }
677
+ ```
678
+
679
+ ### Cloudflare R2
680
+
681
+ ```typescript
682
+ import { createR2Adapter } from 'opacacms/storage/r2';
683
+
684
+ storages: {
685
+ default: createR2Adapter({
686
+ bucketBinding: env.BUCKET,
687
+ publicUrl: 'https://assets.example.com',
688
+ }),
689
+ secure: createR2Adapter({
690
+ bucketBinding: env.SECURE_BUCKET,
691
+ publicUrl: 'https://assets.example.com',
692
+ }),
693
+ }
694
+ ```
695
+
696
+ ### Local filesystem (development)
697
+
698
+ ```typescript
699
+ import { createLocalAdapter } from 'opacacms/storage/local';
700
+
701
+ storages: {
702
+ default: createLocalAdapter({
703
+ directory: './public/uploads',
704
+ publicUrl: 'http://localhost:3000/uploads',
705
+ }),
706
+ }
707
+ ```
708
+
709
+ ---
710
+
711
+ ## Internationalization (i18n)
712
+
713
+ ```typescript
714
+ export default defineConfig({
715
+ i18n: {
716
+ locales: ['en', 'pt-BR', 'es'],
717
+ defaultLocale: 'pt-BR',
718
+ },
719
+ });
720
+ ```
721
+
722
+ Mark individual fields as localized with `.localized()`:
723
+
724
+ ```typescript
725
+ Field.text('title').localized().required();
726
+ Field.richText('body').localized();
727
+ Field.text('slug'); // not localized — same value across all locales
728
+ ```
729
+
730
+ The API resolves the locale from the `x-opaca-locale` header or `?locale=` query param, falling back to `defaultLocale`. Pass `?locale=all` to get all locales at once.
731
+
732
+ ---
733
+
734
+ ## Custom Admin Components
735
+
736
+ You can replace any field's input or table cell with your own component. This works with **any framework** — React, Vue, Vanilla JS — by implementing the `defineCustomField` adapter.
737
+
738
+ ### 1. Register a custom field tag
739
+
740
+ ```typescript
741
+ // src/admin/my-components.ts
742
+ import { defineCustomField } from 'opacacms/admin';
743
+ import { createRoot } from 'react-dom/client';
744
+ import MyColorPicker from './MyColorPicker';
745
+
746
+ defineCustomField('my-color-picker', {
747
+ mount(container, props) {
748
+ (container as any)._root = createRoot(container);
749
+ (container as any)._root.render(<MyColorPicker {...props} />);
750
+ },
751
+ update(container, props) {
752
+ (container as any)._root.render(<MyColorPicker {...props} />);
753
+ },
754
+ unmount(container) {
755
+ (container as any)._root.unmount();
756
+ },
757
+ });
758
+ ```
759
+
760
+ The `props` object your component receives:
761
+
762
+ | Prop | Type | Description |
763
+ | ------------- | --------------- | ------------------------------------------- |
764
+ | `value` | `any` | Current field value |
765
+ | `onChange` | `(val) => void` | Call to update the value |
766
+ | `fieldConfig` | `object` | The field's schema definition |
767
+ | `disabled` | `boolean` | True if access control disabled the field |
768
+ | `readOnly` | `boolean` | True if access control made it read-only |
769
+ | `error` | `string?` | Validation error message |
770
+ | `parentData` | `object?` | All other field values in the same document |
771
+
772
+ ### 2. Reference in the schema
773
+
774
+ ```typescript
775
+ Field.text('primaryColor').admin({
776
+ components: {
777
+ Field: 'my-color-picker', // used in the edit form
778
+ Cell: 'my-color-cell', // used in the collection list table
779
+ },
780
+ });
781
+ ```
782
+
783
+ ### 3. Load your script
784
+
785
+ Your custom component script must be loaded in the browser before the admin UI mounts. For Cloudflare Workers, serve the file as a static asset and include it as a `<script>` tag in the admin HTML page. For Next.js, import it in your admin layout.
786
+
787
+ ---
788
+
789
+ ## The Client SDK
790
+
791
+ Use the typed client to query your CMS from any frontend:
792
+
793
+ ```typescript
794
+ import { createClient } from 'opacacms/client';
795
+
796
+ const cms = createClient({ baseURL: 'https://my-cms.example.com' });
797
+
798
+ // List with filtering and pagination
799
+ const result = await cms.collections.posts.find({
800
+ '_status[equals]': 'published',
801
+ limit: 10,
802
+ page: 1,
803
+ });
804
+ console.log(result.docs); // array of documents
805
+ console.log(result.totalPages);
806
+
807
+ // Single document
808
+ const post = await cms.collections.posts.findOne('abc-123');
809
+
810
+ // Create / Update / Delete
811
+ const newPost = await cms.collections.posts.create({ title: 'Hello' });
812
+ await cms.collections.posts.update('abc-123', { title: 'Updated' });
813
+ await cms.collections.posts.delete('abc-123');
814
+
815
+ // Globals
816
+ const settings = await cms.globals['site-settings'].get();
817
+ await cms.globals['site-settings'].update({ siteName: 'New Name' });
818
+ ```
819
+
820
+ ### Typed client with generated types
821
+
822
+ ```bash
823
+ bunx opacacms generate:types --url http://localhost:3000 --out opaca-types.d.ts
824
+ ```
825
+
826
+ ```typescript
827
+ import { createClient } from 'opacacms/client';
828
+ import type { GeneratedTypes } from './opaca-types';
829
+
830
+ const cms = createClient<GeneratedTypes>({ baseURL: '...' });
831
+
832
+ // IDE autocomplete now knows the exact shape of every collection
833
+ const post = await cms.collections.posts.findOne('123');
834
+ // post.title → string ✓
835
+ ```
836
+
837
+ ---
838
+
839
+ ## Runtime Integrations
840
+
841
+ ### Next.js
842
+
843
+ Create a catch-all API route:
844
+
845
+ ```
846
+ src/app/api/[[...route]]/route.ts
847
+ ```
848
+
849
+ ```typescript
850
+ // src/app/api/[[...route]]/route.ts
851
+ import { createNextHandler } from 'opacacms/runtimes/next';
852
+ import config from '../../../opacacms.config';
853
+
854
+ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } =
855
+ createNextHandler(config);
856
+ ```
857
+
858
+ ```typescript
859
+ // opacacms.config.ts
860
+ import { defineConfig } from 'opacacms';
861
+ import { createPostgresAdapter } from 'opacacms/db/postgres';
862
+
863
+ export default defineConfig({
864
+ appName: 'My Next.js CMS',
865
+ serverURL: process.env.NEXT_PUBLIC_API_URL!,
866
+ secret: process.env.OPACA_SECRET!,
867
+ db: createPostgresAdapter(process.env.DATABASE_URL!),
868
+ collections: [
869
+ /* posts.build(), ... */
870
+ ],
871
+ });
872
+ ```
873
+
874
+ Serve the Admin UI from a Next.js page:
875
+
876
+ ```tsx
877
+ // src/app/admin/[[...all]]/page.tsx
878
+ export default function AdminPage() {
879
+ return (
880
+ <html>
881
+ <body>
882
+ <opaca-admin server-url={process.env.NEXT_PUBLIC_API_URL} />
883
+ <script type="module" src="/opacacms/webcomponent.js" />
884
+ </body>
885
+ </html>
886
+ );
887
+ }
888
+ ```
889
+
890
+ ---
891
+
892
+ ### Cloudflare Workers
893
+
894
+ ```typescript
895
+ // src/index.ts
896
+ import { createCloudflareWorkersHandler } from 'opacacms/runtimes/cloudflare-workers';
897
+ import getConfig from './opacacms.config';
898
+
899
+ const app = createCloudflareWorkersHandler(getConfig);
900
+
901
+ // Serve the Admin UI for /admin/* routes
902
+ app.get('/admin*', (c) => {
903
+ return c.html(`
904
+ <!DOCTYPE html>
905
+ <html>
906
+ <head>
907
+ <title>Admin</title>
908
+ <link rel="stylesheet" href="/admin.css">
909
+ </head>
910
+ <body>
911
+ <opaca-admin server-url="${new URL(c.req.url).origin}"></opaca-admin>
912
+ <script type="module" src="/webcomponent.js"></script>
913
+ </body>
914
+ </html>
915
+ `);
916
+ });
917
+
918
+ export default app;
919
+ ```
920
+
921
+ ```typescript
922
+ // opacacms.config.ts
923
+ import { defineConfig } from 'opacacms';
924
+ import { createD1Adapter } from 'opacacms/db/d1';
925
+ import { createR2Adapter } from 'opacacms/storage/r2';
926
+
927
+ // Export as a function to access per-request Cloudflare bindings
928
+ const getConfig = (env: Env, request: Request) =>
929
+ defineConfig({
930
+ appName: 'My Workers CMS',
931
+ serverURL: new URL(request.url).origin,
932
+ secret: env.OPACA_SECRET,
933
+ trustedOrigins: ['https://my-frontend.com'],
934
+ db: createD1Adapter(env.DB),
935
+ storages: {
936
+ default: createR2Adapter({
937
+ bucketBinding: env.BUCKET,
938
+ publicUrl: new URL(request.url).origin,
939
+ }),
940
+ },
941
+ auth: {
942
+ features: { apiKeys: { enabled: true } },
943
+ },
944
+ i18n: {
945
+ locales: ['en', 'pt-BR'],
946
+ defaultLocale: 'pt-BR',
947
+ },
948
+ collections: [
949
+ /* posts.build(), ... */
950
+ ],
951
+ globals: [
952
+ /* siteSettings.build(), ... */
953
+ ],
954
+ });
955
+
956
+ export default getConfig;
957
+ ```
958
+
959
+ ```jsonc
960
+ // wrangler.jsonc
961
+ {
962
+ "name": "my-cms",
963
+ "compatibility_date": "2024-09-23",
964
+ "d1_databases": [
965
+ { "binding": "DB", "database_name": "my-cms-db", "database_id": "..." },
966
+ ],
967
+ "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "my-cms-assets" }],
968
+ }
969
+ ```
970
+
971
+ ---
972
+
973
+ ### Bun (standalone)
974
+
975
+ ```typescript
976
+ // src/index.ts
977
+ import { createBunHandler } from 'opacacms/runtimes/bun';
978
+ import config from './opacacms.config';
979
+
980
+ const { init } = createBunHandler(config, { port: 3000 });
981
+ await init();
982
+ ```
983
+
984
+ ---
985
+
986
+ ### Node.js
987
+
988
+ ```typescript
989
+ // src/index.ts
990
+ import { createNodeHandler } from 'opacacms/runtimes/node';
991
+ import config from './opacacms.config';
992
+
993
+ const { init } = createNodeHandler(config, { port: 3000 });
994
+ await init();
995
+ ```
996
+
997
+ ---
998
+
999
+ ## CLI Reference
1000
+
1001
+ The CLI auto-detects your config at `opacacms.config.ts`, `src/opacacms.config.ts`, or `index.ts`.
1002
+
1003
+ ```bash
1004
+ # Scaffold a new project
1005
+ bunx opacacms init my-project
1006
+
1007
+ # Generate migrations from your current schema
1008
+ bunx opacacms migrate:create <name>
1009
+
1010
+ # Apply pending migrations (Postgres / SQLite)
1011
+ bunx opacacms migrate
1012
+
1013
+ # Check which migrations have been applied
1014
+ bunx opacacms migrate:status
1015
+
1016
+ # Apply SQL migrations to Cloudflare D1
1017
+ bunx opacacms migrate:d1 --local # local dev
1018
+ bunx opacacms migrate:d1 --remote # production
1019
+
1020
+ # Seed the database with fake data
1021
+ bunx opacacms seed --count 50
1022
+ bunx opacacms seed:assets --count 20
1023
+
1024
+ # Generate TypeScript types from a running instance
1025
+ bunx opacacms generate:types --url http://localhost:3000 --out opaca-types.d.ts
1026
+
1027
+ # Use a custom config file path
1028
+ bunx opacacms migrate:create --config ./path/to/config.ts
1029
+ ```
1030
+
1031
+ ---
1032
+
1033
+ ## Production Build
1034
+
1035
+ ### Disable push mode
1036
+
1037
+ ```typescript
1038
+ db: createPostgresAdapter(process.env.DATABASE_URL, {
1039
+ push: false, // never auto-alter schema in production
1040
+ });
1041
+ ```
1042
+
1043
+ ### Required environment variables
1044
+
1045
+ ```bash
1046
+ OPACA_SECRET=<random-32-char-string> # required
1047
+ DATABASE_URL=postgres://... # for Postgres
1048
+ ```
1049
+
1050
+ ### Deploy flow
1051
+
1052
+ ```bash
1053
+ # Generate + apply migrations
1054
+ bunx opacacms migrate:create latest
1055
+ bunx opacacms migrate # Postgres / SQLite
1056
+ # or
1057
+ bunx opacacms migrate:d1 --remote # Cloudflare D1
1058
+
1059
+ # Start the server
1060
+ node dist/index.js
1061
+ # or
1062
+ wrangler deploy
1063
+ ```
@@ -1249,7 +1249,6 @@ function createDatabaseInitMiddleware(config, state) {
1249
1249
  }
1250
1250
 
1251
1251
  // src/server/middlewares/rate-limit.ts
1252
- import { WorkersKVStore } from "@hono-rate-limiter/cloudflare";
1253
1252
  import { rateLimiter } from "hono-rate-limiter";
1254
1253
  function createRateLimitMiddleware(config) {
1255
1254
  const rateLimitConfig = config.api?.rateLimit;
@@ -1277,7 +1276,10 @@ function createRateLimitMiddleware(config) {
1277
1276
  if (!resolvedStore && c.env) {
1278
1277
  const kvBindingKey = Object.keys(c.env).find((key) => key.startsWith("OPACA_") && c.env[key]?.put && c.env[key]?.get);
1279
1278
  if (kvBindingKey) {
1280
- resolvedStore = new WorkersKVStore({ namespace: c.env[kvBindingKey] });
1279
+ try {
1280
+ const { WorkersKVStore } = await import("@hono-rate-limiter/cloudflare");
1281
+ resolvedStore = new WorkersKVStore({ namespace: c.env[kvBindingKey] });
1282
+ } catch (_) {}
1281
1283
  }
1282
1284
  }
1283
1285
  const limiter = rateLimiter({
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createAPIRouter
3
- } from "../chunk-ry15hke8.js";
3
+ } from "../chunk-yr32cp7h.js";
4
4
  import"../chunk-62ev8gnc.js";
5
5
  import"../chunk-cvdd4eqh.js";
6
6
  import"../chunk-ybbbqj63.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createAPIRouter
3
- } from "../chunk-ry15hke8.js";
3
+ } from "../chunk-yr32cp7h.js";
4
4
  import"../chunk-62ev8gnc.js";
5
5
  import"../chunk-cvdd4eqh.js";
6
6
  import"../chunk-ybbbqj63.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createAPIRouter
3
- } from "../chunk-ry15hke8.js";
3
+ } from "../chunk-yr32cp7h.js";
4
4
  import"../chunk-62ev8gnc.js";
5
5
  import"../chunk-cvdd4eqh.js";
6
6
  import"../chunk-ybbbqj63.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createAPIRouter
3
- } from "../chunk-ry15hke8.js";
3
+ } from "../chunk-yr32cp7h.js";
4
4
  import"../chunk-62ev8gnc.js";
5
5
  import"../chunk-cvdd4eqh.js";
6
6
  import"../chunk-ybbbqj63.js";
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  createGlobalHandlers,
5
5
  createHandlers,
6
6
  hydrateDoc
7
- } from "./chunk-ry15hke8.js";
7
+ } from "./chunk-yr32cp7h.js";
8
8
  import {
9
9
  defineConfig
10
10
  } from "./chunk-zvwb67nd.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opacacms",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "sideEffects": false,
5
5
  "scripts": {
6
6
  "build": "bun run ../../scripts/build.ts && tsc --emitDeclarationOnly",
@@ -145,11 +145,13 @@
145
145
  }
146
146
  },
147
147
  "devDependencies": {
148
+ "@better-auth/core": "^1.5.5",
148
149
  "@cloudflare/workers-types": "^4.20260313.1",
149
150
  "@faker-js/faker": "^10.3.0",
150
151
  "@happy-dom/global-registrator": "^20.8.4",
151
152
  "@testing-library/dom": "^10.4.1",
152
153
  "@testing-library/react": "^16.3.2",
154
+ "@types/better-sqlite3": "^7.6.13",
153
155
  "@types/bun": "latest",
154
156
  "@types/react": "^19.2.14",
155
157
  "@types/react-dom": "^19.2.3",
@@ -262,8 +264,6 @@
262
264
  },
263
265
  "dependencies": {
264
266
  "@better-auth/api-key": "^1.5.5",
265
- "@better-auth/core": "^1.5.5",
266
- "@types/better-sqlite3": "^7.6.13",
267
267
  "better-auth": "^1.5.5",
268
268
  "hono": "^4.12.7",
269
269
  "ky": "^1.14.3",