create-sonicjs 3.0.0-beta.15 → 3.0.0-beta.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-sonicjs",
3
- "version": "3.0.0-beta.15",
3
+ "version": "3.0.0-beta.17",
4
4
  "description": "Create a new SonicJS application with zero configuration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -409,7 +409,7 @@ async function copyTemplate(templateName, targetDir, options) {
409
409
 
410
410
  // Add @sonicjs-cms/core dependency
411
411
  packageJson.dependencies = {
412
- '@sonicjs-cms/core': '^3.0.0-beta.15',
412
+ '@sonicjs-cms/core': '^3.0.0-beta.17',
413
413
  ...packageJson.dependencies
414
414
  }
415
415
 
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * My SonicJS Application
3
3
  *
4
- * Entry point for your SonicJS headless CMS application
4
+ * Entry point for your SonicJS headless CMS application.
5
+ * The example plugin is included to demonstrate how plugins work —
6
+ * feel free to remove it or use it as a starting point for your own.
5
7
  */
6
8
 
7
9
  import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core'
@@ -11,22 +13,26 @@ import type { SonicJSConfig } from '@sonicjs-cms/core'
11
13
  // Add new collections here after creating them in src/collections/
12
14
  import blogPostsCollection from './collections/blog-posts.collection'
13
15
 
14
- // Register collections BEFORE creating the app
15
- // This ensures they are synced to the database on startup
16
+ // Example plugin — demonstrates routes, admin UI, collections, hooks, and settings.
17
+ // Remove this import (and the register entry below) when you no longer need it.
18
+ import { examplePlugin } from './plugins/example'
19
+ import { moodsCollection } from './plugins/example/collections/moods.collection'
20
+
21
+ // Register collections BEFORE creating the app.
16
22
  registerCollections([
17
23
  blogPostsCollection,
24
+ moodsCollection,
18
25
  // Add more collections here as you create them
19
26
  ])
20
27
 
21
28
  // Application configuration
22
29
  const config: SonicJSConfig = {
23
- collections: {
24
- autoSync: true
25
- },
26
30
  plugins: {
27
- directory: './src/plugins',
28
- autoLoad: false // Set to true to auto-load custom plugins
29
- }
31
+ register: [
32
+ examplePlugin,
33
+ // Add your own plugins here
34
+ ],
35
+ },
30
36
  }
31
37
 
32
38
  // Create and export the application
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Example Plugin — Moods Collection
3
+ *
4
+ * Demonstrates how a plugin contributes a collection to the document repository.
5
+ * Collections are code-defined (no DB table) — register via registerCollections()
6
+ * in the app entry point. The core then auto-registers a document_type row at
7
+ * bootstrap and exposes full CRUD at /admin/content/example.
8
+ *
9
+ * This collection stores moods that the plugin's public API randomly selects from.
10
+ */
11
+
12
+ import type { CollectionConfig } from '@sonicjs-cms/core'
13
+
14
+ export const moodsCollection = {
15
+ name: 'example',
16
+ displayName: 'Example',
17
+ slug: 'example',
18
+ description: 'Moods served by the Example plugin API',
19
+ icon: '😈',
20
+
21
+ schema: {
22
+ type: 'object',
23
+ properties: {
24
+ name: {
25
+ type: 'string',
26
+ title: 'Mood Name',
27
+ required: true,
28
+ maxLength: 100,
29
+ },
30
+ emoji: {
31
+ type: 'string',
32
+ title: 'Emoji',
33
+ maxLength: 10,
34
+ },
35
+ description: {
36
+ type: 'string',
37
+ title: 'Description',
38
+ maxLength: 300,
39
+ },
40
+ },
41
+ required: ['name'],
42
+ },
43
+
44
+ listFields: ['name', 'emoji', 'description'],
45
+ searchFields: ['name', 'description'],
46
+ defaultSort: 'createdAt',
47
+ defaultSortOrder: 'asc' as const,
48
+
49
+ managed: true,
50
+ isActive: true,
51
+
52
+ // Publicly readable so the API endpoint can list moods without auth.
53
+ access: {
54
+ public: ['read'],
55
+ },
56
+ } satisfies CollectionConfig
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Hello Cruel World Plugin
3
+ *
4
+ * A heavily-commented "hello world" plugin for SonicJS v3. Its purpose is to
5
+ * demonstrate every major extension point the plugin system provides:
6
+ *
7
+ * 1. Route registration — public API + admin page
8
+ * 2. Declarative menu entry — shows up in the admin sidebar
9
+ * 3. Hook subscriptions — react to content and auth events
10
+ * 4. configSchema — auto-rendered settings form in the admin
11
+ * 5. Lifecycle callbacks — install / activate / deactivate / uninstall
12
+ * 6. onBoot — async one-time setup per Worker isolate
13
+ *
14
+ * Read the comments top-to-bottom; they explain WHY each piece exists, not just
15
+ * what it is.
16
+ *
17
+ * ── The v3 Plugin API ────────────────────────────────────────────────────────
18
+ *
19
+ * In SonicJS v3 every plugin is created with `definePlugin()`. Under the hood
20
+ * definePlugin:
21
+ * - validates `id` and `version` (semver)
22
+ * - warns on unknown `capabilities`
23
+ * - wraps `onBoot` / `onCronTick` to inject a richer typed context
24
+ * - flattens the declarative `hooks` map into the wirable hooks array
25
+ * - returns an object that satisfies `MountablePlugin`, `WirablePlugin`, and
26
+ * `CronablePlugin` so the runtime never needs adapters
27
+ *
28
+ * ── Two-phase boot ───────────────────────────────────────────────────────────
29
+ *
30
+ * Cloudflare Workers start cold on the first request. The plugin system splits
31
+ * work into two strict phases:
32
+ *
33
+ * Phase 1 — register(app) — SYNCHRONOUS
34
+ * Mount routes and middleware. Hono locks its router after the first request
35
+ * so this MUST be sync and complete before any request arrives.
36
+ *
37
+ * Phase 2 — onBoot(ctx) — ASYNC
38
+ * Run once after all plugins have registered (first request warm-up).
39
+ * Safe to await D1 queries, register dynamic hooks, or read env bindings.
40
+ *
41
+ * ── Capabilities ─────────────────────────────────────────────────────────────
42
+ *
43
+ * Capabilities gate access to optional services (email, R2 media, etc.). A
44
+ * plugin that doesn't declare a capability can't accidentally call it — the
45
+ * TypeScript type is `never` and the runtime throws. This plugin needs none.
46
+ */
47
+
48
+ import { definePlugin, PluginServiceClass as PluginService, DocumentRepository, DocumentsService } from '@sonicjs-cms/core'
49
+ import { createExampleApiRoutes } from './routes/api'
50
+ import { createExampleAdminRoutes } from './routes/admin'
51
+
52
+ // Shared mutable options object — register() passes a reference to the routes;
53
+ // onBoot() mutates it after loading DB settings so route handlers see live values.
54
+ const pluginOptions: { greeting: string; defaultName: string } = {
55
+ greeting: 'Hello, Cruel World!',
56
+ defaultName: 'Stranger',
57
+ }
58
+
59
+ // Type ID for the moods collection — matches the collection name.
60
+ const MOODS_TYPE_ID = 'example'
61
+
62
+ // Default moods seeded on first boot if the collection is empty.
63
+ const DEFAULT_MOODS = [
64
+ { name: 'Cruel', emoji: '😈', description: 'Classic flavour — cold, merciless, unapologetic.' },
65
+ { name: 'Melancholy', emoji: '🌧️', description: 'The world is grey and damp and vaguely disappointing.' },
66
+ { name: 'Chaotic', emoji: '🌀', description: 'Nothing makes sense and that is perfectly fine.' },
67
+ ]
68
+
69
+ // ── Plugin definition ─────────────────────────────────────────────────────────
70
+ //
71
+ // definePlugin<Caps>() is generic. The `Caps` type parameter captures the
72
+ // `capabilities` tuple so ctx.cap is narrowed at the author's call site.
73
+ // We omit capabilities entirely (defaults to readonly []) since this plugin
74
+ // needs no optional services.
75
+
76
+ export const examplePlugin = definePlugin({
77
+ // ── Identity fields ─────────────────────────────────────────────────────────
78
+ //
79
+ // `id` is the stable machine key — used as the DB key for settings, the
80
+ // settings URL segment (/admin/settings/plugins/:id), and dependency keys.
81
+ // Use kebab-case. Never change this once the plugin ships.
82
+ id: 'example',
83
+
84
+ // `name` is the human-readable display name in the admin UI.
85
+ name: 'Example',
86
+
87
+ // `version` must be valid semver (X.Y.Z). definePlugin warns if it isn't.
88
+ version: '1.0.0',
89
+
90
+ // `description` shows up in the plugin list and settings page header.
91
+ description: 'A demo plugin that explains the SonicJS v3 plugin system.',
92
+
93
+ // `sonicjsVersionRange` is a semver range checked against the running core
94
+ // at registration. A mismatch logs a warning but never blocks activation —
95
+ // plugins stay resilient by default.
96
+ sonicjsVersionRange: '^3.0.0',
97
+
98
+ // `author` appears in the plugin registry and manifest.
99
+ author: {
100
+ name: 'You',
101
+ email: 'you@example.com',
102
+ url: 'https://example.com',
103
+ },
104
+
105
+ // ── Route registration (SYNC) ─────────────────────────────────────────────
106
+ //
107
+ // `register(app)` is called synchronously during app setup, before any
108
+ // request arrives. `app` is the root Hono instance — use app.route() to
109
+ // mount sub-routers, or app.use() for middleware.
110
+ //
111
+ // IMPORTANT: Never await anything here. Never read env bindings here.
112
+ // Async work belongs in onBoot().
113
+ register(app) {
114
+ // ── Why NOT /api/hello-cruel-world ────────────────────────────────────────
115
+ //
116
+ // User plugins are mounted in app.ts AFTER the core `/api` router, which
117
+ // has a `/:collection` wildcard catch-all. In Hono, routes match in
118
+ // registration order, so `/api/hello-cruel-world` would be swallowed by
119
+ // `/:collection` → "Collection not found".
120
+ //
121
+ // Three options for a user plugin that needs a public API:
122
+ // a) Use a top-level path: /hello-cruel-world ← what we do here
123
+ // b) Coordinate with core to mount before the catch-all (core-plugin only)
124
+ // c) POST to /api/forms or another dedicated catch-all (plugin-provided)
125
+ //
126
+ // The public API is at /hello-cruel-world/* (no /api/ prefix).
127
+ // Pass pluginOptions by reference — onBoot() mutates it after loading DB settings.
128
+ app.route('/example', createExampleApiRoutes(pluginOptions) as any)
129
+
130
+ // Admin routes live under /admin/*, which also has a catch-all, but user
131
+ // plugins are mounted BEFORE the /admin catch-all, so this works fine.
132
+ app.route('/admin/example', createExampleAdminRoutes(pluginOptions) as any)
133
+ },
134
+
135
+ // ── Admin sidebar menu ────────────────────────────────────────────────────
136
+ //
137
+ // The `menu` array declares sidebar entries. The catalyst layout reads these
138
+ // from the plugin menu singleton (populated by registerPlugins) and renders
139
+ // them automatically — no per-plugin middleware needed.
140
+ //
141
+ // Fields:
142
+ // label — displayed text
143
+ // path — href (absolute from root)
144
+ // icon — icon key understood by the catalyst icon renderer
145
+ // order — sort position in the sidebar (lower = higher up)
146
+ // permissions — roles that can see this entry (admin, editor, etc.)
147
+ menu: [
148
+ {
149
+ label: 'Example',
150
+ path: '/admin/example',
151
+ // 'globe-alt' is one of the Heroicons names available in the catalyst UI.
152
+ icon: 'globe-alt',
153
+ // 90 puts this near the bottom of the sidebar, above the Settings item.
154
+ order: 90,
155
+ // Only admins see this. Remove 'admin' to make it visible to all roles.
156
+ permissions: ['admin'],
157
+ },
158
+ ],
159
+
160
+ // ── Declarative hook subscriptions ────────────────────────────────────────
161
+ //
162
+ // The `hooks` map is the declarative way to subscribe to typed lifecycle
163
+ // events. Each key is a canonical event name from the hook catalog
164
+ // (packages/core/src/plugins/hooks/catalog.ts), and the value is the
165
+ // handler function.
166
+ //
167
+ // The TypeScript type of each handler is narrowed to its event's payload —
168
+ // so `payload` here is `ContentEventPayload`, not `any`.
169
+ //
170
+ // Declarative hooks are registered during the wire phase (before the first
171
+ // request). For dynamic / conditional subscriptions use `ctx.hooks.on()`
172
+ // inside `onBoot` instead.
173
+ hooks: {
174
+ // Fired AFTER a content record is successfully created.
175
+ // 'before' hooks (content:before:create) run before the write and can
176
+ // mutate the payload or throw to cancel the operation.
177
+ // 'after' hooks run post-commit and are for side effects only.
178
+ 'content:after:create': (payload) => {
179
+ // `payload` is typed: { collection, id, data, user? }
180
+ console.log(
181
+ `[example] New content created in collection "${payload.collection}"`,
182
+ { id: payload.id, user: payload.user?.email ?? 'anonymous' }
183
+ )
184
+ // Returning void (or the payload unchanged) passes control to the next
185
+ // registered handler for this event. Returning a modified payload
186
+ // propagates the change to subsequent handlers.
187
+ },
188
+
189
+ // Fired AFTER a user completes self-registration.
190
+ // payload is typed: { user: { id, email, role? } }
191
+ 'auth:registration:completed': (payload) => {
192
+ console.log(
193
+ `[example] Welcome to the cruel world, ${payload.user.email}!`
194
+ )
195
+ },
196
+ },
197
+
198
+ // ── Async boot (ASYNC) ────────────────────────────────────────────────────
199
+ //
200
+ // `onBoot(ctx)` runs once per Worker isolate, on the first request, after
201
+ // ALL plugins have completed their synchronous `register()` calls. This is
202
+ // the right place for:
203
+ // - Reading env bindings (ctx.env.DB, ctx.env.KV, etc.)
204
+ // - Registering document types
205
+ // - Loading settings from the DB
206
+ // - Subscribing to hooks dynamically based on config
207
+ //
208
+ // `ctx` is the DefinedPluginContext — richer than the raw PluginBootContext:
209
+ // ctx.hooks — typed hook facade (ctx.hooks.on / ctx.hooks.emit)
210
+ // ctx.cap — capability-gated services (ctx.cap.email, etc.)
211
+ // ctx.env — Worker env bindings (DB, KV, R2, secrets, etc.)
212
+ // ctx.raw — the underlying PluginBootContext if you need the untyped form
213
+ async onBoot(ctx) {
214
+ // Logging here goes to the Wrangler console (local dev) or Cloudflare
215
+ // Logpush in production. Use console.log sparingly in hot paths.
216
+ console.log('[example] Plugin booting...')
217
+
218
+ // ── Self-register in the DB so /admin/plugins shows this plugin ────────
219
+ //
220
+ // The /admin/plugins list reads from two sources:
221
+ // 1. PLUGIN_REGISTRY — auto-generated from manifest.json files in
222
+ // packages/core/src/plugins/ (core plugins only)
223
+ // 2. PluginService.getAllPlugins() — documents of type 'plugin' in the DB
224
+ //
225
+ // User plugins registered via config.plugins.register are functionally
226
+ // active (routes, hooks, onBoot all fire) but invisible to the list page
227
+ // unless they appear in one of those sources.
228
+ //
229
+ // PluginService.ensurePlugin() is the idempotent escape hatch: it writes
230
+ // a plugin document on first boot, then no-ops on subsequent warm-ups.
231
+ // This makes the plugin visible in /admin/plugins without a manifest.json.
232
+ const db = ctx.env?.DB as import('@cloudflare/workers-types').D1Database | undefined
233
+ if (db) {
234
+ try {
235
+ const svc = new PluginService(db)
236
+
237
+ // Ensure the plugin record exists (no-op if already present).
238
+ await svc.ensurePlugin('example', {
239
+ displayName: 'Example',
240
+ description: 'A demo plugin that explains the SonicJS v3 plugin system.',
241
+ author: 'You',
242
+ version: '1.0.0',
243
+ })
244
+
245
+ // ── Sync route metadata into plugin settings ───────────────────────
246
+ //
247
+ // The admin /admin/plugins/:id "Information" tab reads
248
+ // plugin.settings._routes to render the route list automatically.
249
+ // We store it in the DB settings JSON so the template never needs to
250
+ // know about this specific plugin.
251
+ //
252
+ // Pattern for any plugin: merge _routes into existing settings on every
253
+ // boot so the list stays in sync if routes are added/removed.
254
+ //
255
+ // _routes shape:
256
+ // method — HTTP method (GET, POST, …)
257
+ // path — full path as mounted on the root app
258
+ // description — what the route does
259
+ // requiresAuth — true = admin/session required; false = public
260
+ const existing = await svc.getPlugin('example')
261
+ const existingSettings = (existing?.settings as Record<string, unknown>) ?? {}
262
+
263
+ // Seed configSchema defaults on first boot so user-configurable keys exist
264
+ // in the DB and the Settings tab is visible in the admin UI.
265
+ const mergedSettings: Record<string, unknown> = {
266
+ greeting: 'Hello, Cruel World!',
267
+ defaultName: 'Stranger',
268
+ showTimestamp: true,
269
+ mood: 'cruel',
270
+ ...existingSettings, // saved values win over defaults
271
+ }
272
+
273
+ // Apply resolved settings to pluginOptions so route handlers see them.
274
+ if (typeof mergedSettings.greeting === 'string') pluginOptions.greeting = mergedSettings.greeting
275
+ if (typeof mergedSettings.defaultName === 'string') pluginOptions.defaultName = mergedSettings.defaultName
276
+
277
+ await svc.updatePluginSettings('example', {
278
+ ...mergedSettings,
279
+ _adminPath: '/admin/example',
280
+ _routes: [
281
+ {
282
+ method: 'GET',
283
+ path: '/example',
284
+ description: 'Returns a JSON greeting message',
285
+ requiresAuth: false,
286
+ },
287
+ {
288
+ method: 'GET',
289
+ path: '/example/:name',
290
+ description: 'Returns a personalised greeting for :name',
291
+ requiresAuth: false,
292
+ },
293
+ {
294
+ method: 'GET',
295
+ path: '/example/moods',
296
+ description: 'Lists all published moods as JSON',
297
+ requiresAuth: false,
298
+ },
299
+ {
300
+ method: 'GET',
301
+ path: '/admin/example',
302
+ description: 'Admin dashboard page for this plugin',
303
+ requiresAuth: true,
304
+ },
305
+ {
306
+ method: 'GET',
307
+ path: '/admin/content?model=example&page=1',
308
+ description: 'CRUD admin for the moods collection (core-provided)',
309
+ requiresAuth: true,
310
+ },
311
+ ],
312
+ })
313
+ } catch (e) {
314
+ // Non-fatal — plugin still works, just won't show in the list.
315
+ console.warn('[example] Could not self-register in DB:', e)
316
+ }
317
+
318
+ // ── Seed default moods ─────────────────────────────────────────────────
319
+ //
320
+ // On first boot the moods collection is empty. We use DocumentsService to
321
+ // create + immediately publish 3 default moods so the API has data to serve.
322
+ //
323
+ // DocumentsService.create() with publishOnCreate:true is the idiomatic
324
+ // one-step pattern for seeding reference data that should be live immediately.
325
+ //
326
+ // We guard with a COUNT query to stay idempotent — subsequent warm-ups skip
327
+ // seeding if any mood documents already exist.
328
+ try {
329
+ // Ensure the document type row exists before seeding. plugin onBoot can
330
+ // race bootstrapMiddleware on the very first request (e.g. a static-asset
331
+ // or favicon hits wirePlugins before bootstrap has run
332
+ // autoRegisterCollectionDocumentTypes). D1 local enforces the FK on
333
+ // documents.type_id, so the seed insert would fail silently. INSERT OR
334
+ // IGNORE here is idempotent and harmless when the row already exists.
335
+ await db!.prepare(
336
+ `INSERT OR IGNORE INTO document_types (id, name, display_name, source, settings, created_at, updated_at)
337
+ VALUES (?, ?, ?, 'system', '{"baseGrants":{"public":["read"],"admin":["read","create","update","delete","publish","manage"]}}', strftime('%s','now'), strftime('%s','now'))`
338
+ ).bind(MOODS_TYPE_ID, MOODS_TYPE_ID, 'Example').run()
339
+
340
+ const countRow = await db!
341
+ .prepare(`SELECT COUNT(*) as n FROM documents WHERE type_id = ? AND tenant_id = ? AND deleted_at IS NULL`)
342
+ .bind(MOODS_TYPE_ID, 'default')
343
+ .first<{ n: number }>()
344
+
345
+ if ((countRow?.n ?? 0) === 0) {
346
+ console.log('[example] Seeding default moods...')
347
+ const svc = new DocumentsService(db as any)
348
+ for (const mood of DEFAULT_MOODS) {
349
+ await svc.create(
350
+ {
351
+ typeId: MOODS_TYPE_ID,
352
+ title: mood.name,
353
+ data: mood,
354
+ publishOnCreate: true,
355
+ },
356
+ 'system',
357
+ )
358
+ }
359
+ console.log(`[example] Seeded ${DEFAULT_MOODS.length} moods.`)
360
+ }
361
+ } catch (e) {
362
+ console.warn('[example] Could not seed moods:', e)
363
+ }
364
+ }
365
+
366
+ // ── Reading env bindings ───────────────────────────────────────────────
367
+ //
368
+ // ctx.env is typed as Record<string,unknown> — cast to your expected type.
369
+ // Workers bindings are defined in wrangler.toml:
370
+ // [[d1_databases]]
371
+ // binding = "DB"
372
+ // database_name = "sonicjs"
373
+ //
374
+ const greetingEnvOverride = ctx.env?.EXAMPLE_GREETING as string | undefined
375
+
376
+ if (greetingEnvOverride) {
377
+ // An environment variable can override the plugin's default greeting.
378
+ // In production this might come from a Workers secret or plain env var.
379
+ console.log(
380
+ `[example] Greeting overridden by env var: "${greetingEnvOverride}"`
381
+ )
382
+ }
383
+
384
+ // ── Dynamic hook subscription ─────────────────────────────────────────
385
+ //
386
+ // Use ctx.hooks.on() for subscriptions you only want to register under
387
+ // certain conditions (e.g. only if a config value is set).
388
+ //
389
+ // This is equivalent to the declarative `hooks` map above but happens at
390
+ // boot time so you can branch on env/config.
391
+ ctx.hooks.on('content:after:update', (payload) => {
392
+ // This fires on every content update. payload: ContentEventPayload
393
+ console.log(
394
+ `[example] Content updated in "${payload.collection}" (id: ${payload.id})`
395
+ )
396
+ })
397
+
398
+ console.log('[example] Plugin ready.')
399
+ },
400
+
401
+ // ── configSchema — auto-rendered settings form ────────────────────────────
402
+ //
403
+ // Declaring `configSchema` tells the admin to auto-render a settings form
404
+ // at /admin/settings/plugins/hello-cruel-world. No custom route or template
405
+ // needed — the core renders the form from this schema, handles form
406
+ // submission, parses FormData into typed values, and persists them.
407
+ //
408
+ // Field types: 'string' | 'number' | 'boolean' | 'select'
409
+ // See packages/core/src/plugins/sdk/config-schema.ts for the full API.
410
+ configSchema: {
411
+ // Key = the settings key stored/loaded for this plugin.
412
+ greeting: {
413
+ type: 'string',
414
+ label: 'Custom Greeting',
415
+ description: 'The message returned by the /example endpoint.',
416
+ default: 'Hello, Cruel World!',
417
+ placeholder: 'Hello, Cruel World!',
418
+ },
419
+ defaultName: {
420
+ type: 'string',
421
+ label: 'Default Name',
422
+ description: 'Name used in the greeting when no :name is provided in the URL.',
423
+ default: 'Stranger',
424
+ placeholder: 'Stranger',
425
+ },
426
+ showTimestamp: {
427
+ type: 'boolean',
428
+ label: 'Show Timestamp in API Response',
429
+ description: 'When enabled, the API response includes the current timestamp.',
430
+ default: true,
431
+ },
432
+ mood: {
433
+ type: 'select',
434
+ label: 'Mood',
435
+ description: 'Sets the emotional tone of the greeting (cosmetic only).',
436
+ options: [
437
+ { value: 'cruel', label: '😈 Cruel (default)' },
438
+ { value: 'kind', label: '😇 Kind' },
439
+ { value: 'indifferent', label: '😐 Indifferent' },
440
+ ],
441
+ default: 'cruel',
442
+ },
443
+ },
444
+
445
+ // ── Lifecycle callbacks ───────────────────────────────────────────────────
446
+ //
447
+ // These are called by the plugin manager at specific lifecycle moments.
448
+ // They are NOT called per-request — they run when an admin installs,
449
+ // activates, deactivates, or uninstalls the plugin through the plugin
450
+ // management UI.
451
+ //
452
+ // Common uses:
453
+ // install — run DB migrations, create initial data
454
+ // uninstall — drop plugin tables, clean up data
455
+ // activate — enable plugin features (flip a feature flag, warm a cache)
456
+ // deactivate — disable features without destroying data
457
+
458
+ install: async () => {
459
+ // Called once when an admin installs the plugin.
460
+ // For a plugin with its own DB table you'd run migrations here.
461
+ // This plugin stores nothing — the greeting lives in plugin config.
462
+ console.log('[example] Installed. No DB migrations needed.')
463
+ },
464
+
465
+ activate: async () => {
466
+ // Called each time the plugin is activated (e.g. re-enabled after being
467
+ // disabled). A good place to warm caches or start background jobs.
468
+ console.log('[example] Activated.')
469
+ },
470
+
471
+ deactivate: async () => {
472
+ // Called when an admin disables the plugin. Routes stay mounted (the
473
+ // Worker binary doesn't change at runtime), but you can stop background
474
+ // work and invalidate caches here.
475
+ console.log('[example] Deactivated.')
476
+ },
477
+
478
+ uninstall: async () => {
479
+ // Called when an admin removes the plugin entirely.
480
+ // For a plugin with its own DB table you'd DROP TABLE here.
481
+ console.log('[example] Uninstalled.')
482
+ },
483
+ })
484
+
485
+ // Re-export for consumers that import directly from this file (e.g. tests).
486
+ export default examplePlugin
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Example Plugin — Admin Routes
3
+ *
4
+ * Renders inside the shared admin layout (sidebar, nav, header) by calling
5
+ * renderAdminLayoutCatalyst from @sonicjs-cms/core. This is the DEFAULT
6
+ * pattern for plugin admin pages — provide inner `content` HTML only;
7
+ * the layout owns <html>, <head>, <body>, sidebar, and nav chrome.
8
+ *
9
+ * Pattern (copy this for your own plugin):
10
+ * 1. requireAuth() is applied by the core framework before this route fires.
11
+ * 2. Pull user + pluginMenuItems from context.
12
+ * 3. Build a `content` string — just the page body, no outer chrome.
13
+ * 4. Call renderAdminLayoutCatalyst(layoutData) and return c.html(result).
14
+ *
15
+ * Gear icon:
16
+ * Every plugin admin page should include a gear icon linking to
17
+ * /admin/plugins/<id>#settings. Admins can then jump straight to the
18
+ * settings form without navigating through the Plugins list.
19
+ *
20
+ * Escape hatch:
21
+ * If you need a fully custom page (OAuth callback, print view, embedded
22
+ * widget), return c.html(<full HTML document>) without calling
23
+ * renderAdminLayoutCatalyst. That still works — it just bypasses the
24
+ * shared chrome.
25
+ */
26
+
27
+ import { Hono } from 'hono'
28
+ import { renderAdminLayoutCatalyst, escapeHtml } from '@sonicjs-cms/core'
29
+ import type { AdminLayoutCatalystData } from '@sonicjs-cms/core'
30
+
31
+ export function createExampleAdminRoutes(options: { greeting?: string; defaultName?: string } = {}): Hono {
32
+ const router = new Hono<any>()
33
+
34
+ router.get('/', (c) => {
35
+ const greeting = options.greeting ?? 'Hello, Cruel World!'
36
+ const defaultName = options.defaultName ?? 'Stranger'
37
+ const user = c.get('user') as { email: string; role: string } | undefined
38
+ const dynamicMenuItems = c.get('pluginMenuItems') as Array<{ label: string; path: string; icon: string }> | undefined
39
+
40
+ const content = `
41
+ <div class="w-full px-4 sm:px-6 lg:px-8 py-6">
42
+
43
+ <div class="flex items-center justify-between mb-6">
44
+ <div>
45
+ <h1 class="text-2xl/8 font-semibold text-zinc-950 dark:text-white sm:text-xl/8">Example Plugin</h1>
46
+ <p class="mt-1 text-sm/6 text-zinc-500 dark:text-zinc-400">
47
+ A demo plugin for understanding the SonicJS v3 plugin system.
48
+ </p>
49
+ </div>
50
+ <a
51
+ href="/admin/plugins/example#settings"
52
+ title="Plugin settings"
53
+ class="inline-flex items-center gap-2 rounded-lg bg-zinc-100 dark:bg-zinc-800 px-3 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
54
+ >
55
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
57
+ d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
58
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
59
+ </svg>
60
+ Settings
61
+ </a>
62
+ </div>
63
+
64
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6 mb-6">
65
+ <h2 class="text-lg font-semibold text-white mb-3">Current Config</h2>
66
+ <dl class="space-y-2 text-sm">
67
+ <div class="flex items-center gap-3">
68
+ <dt class="text-gray-400 w-28 shrink-0">Greeting</dt>
69
+ <dd class="text-green-400 font-mono">${escapeHtml(greeting)}</dd>
70
+ </div>
71
+ <div class="flex items-center gap-3">
72
+ <dt class="text-gray-400 w-28 shrink-0">Default name</dt>
73
+ <dd class="text-green-400 font-mono">${escapeHtml(defaultName)}</dd>
74
+ </div>
75
+ </dl>
76
+ <p class="text-xs text-gray-500 mt-3">
77
+ Try: <a href="/example" class="text-blue-400 hover:text-blue-300 font-mono">/example</a>
78
+ → greets <span class="text-green-400 font-mono">${escapeHtml(defaultName)}</span>
79
+ </p>
80
+ </div>
81
+
82
+ <!-- Moods Collection ─────────────────────────────────────────────────
83
+ Shows how a plugin contributes a collection to the document repo.
84
+ The core /admin/content/:collection route provides full CRUD for free —
85
+ the plugin just links to it. -->
86
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6 mb-6">
87
+ <div class="flex items-center justify-between mb-4">
88
+ <div>
89
+ <h2 class="text-lg font-semibold text-white">😈 Moods</h2>
90
+ <p class="text-xs text-gray-400 mt-0.5">
91
+ A random published mood is included in every API response.
92
+ Add, edit, or remove moods below.
93
+ </p>
94
+ </div>
95
+ <a
96
+ href="/admin/content?model=example&page=1"
97
+ class="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-cyan-500 to-purple-600 hover:from-cyan-400 hover:to-purple-500 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-all duration-200"
98
+ >
99
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
100
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
101
+ </svg>
102
+ Manage Moods
103
+ </a>
104
+ </div>
105
+ <p class="text-xs text-gray-500">
106
+ Try: <a href="/example" class="text-blue-400 hover:text-blue-300 font-mono">/example</a>
107
+ → response includes a random <code class="text-purple-400">mood</code> field from this collection.
108
+ </p>
109
+ </div>
110
+
111
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 shadow-xl p-6 mb-6">
112
+ <h2 class="text-lg font-semibold text-white mb-3">How This Plugin Works</h2>
113
+ <dl class="space-y-3 text-sm">
114
+ <div>
115
+ <dt class="text-gray-400 font-medium">Public API</dt>
116
+ <dd><a href="/example" class="text-blue-400 hover:text-blue-300 font-mono">GET /example</a></dd>
117
+ <dd><a href="/example/traveller" class="text-blue-400 hover:text-blue-300 font-mono">GET /example/traveller</a></dd>
118
+ <dd class="text-gray-500 text-xs mt-1">Routes at /example/* not /api/* — user plugins mount after the core /:collection catch-all.</dd>
119
+ </div>
120
+ <div>
121
+ <dt class="text-gray-400 font-medium">Admin page</dt>
122
+ <dd class="text-gray-200 font-mono">GET /admin/example</dd>
123
+ </div>
124
+ <div>
125
+ <dt class="text-gray-400 font-medium">Hook subscriptions</dt>
126
+ <dd class="text-gray-200 font-mono">content:after:create</dd>
127
+ <dd class="text-gray-200 font-mono">auth:registration:completed</dd>
128
+ </div>
129
+ <div>
130
+ <dt class="text-gray-400 font-medium">Settings</dt>
131
+ <dd><a href="/admin/plugins/example#settings" class="text-blue-400 hover:text-blue-300 font-mono">/admin/plugins/example#settings</a></dd>
132
+ </div>
133
+ </dl>
134
+ </div>
135
+
136
+ ${user ? `
137
+ <div class="backdrop-blur-md bg-black/20 rounded-xl border border-white/10 p-4 text-sm text-gray-400">
138
+ Logged in as <span class="text-white font-medium">${escapeHtml(user.email)}</span>
139
+ <span class="ml-2 text-xs text-gray-500">(${escapeHtml(user.role)})</span>
140
+ </div>
141
+ ` : ''}
142
+
143
+ </div>
144
+ `
145
+
146
+ const layoutData: AdminLayoutCatalystData = {
147
+ title: 'Example Plugin',
148
+ pageTitle: 'Example Plugin',
149
+ currentPath: '/admin/example',
150
+ content,
151
+ ...(user ? { user: { name: user.email, email: user.email, role: user.role } } : {}),
152
+ ...(dynamicMenuItems ? { dynamicMenuItems } : {}),
153
+ }
154
+
155
+ return c.html(renderAdminLayoutCatalyst(layoutData))
156
+ })
157
+
158
+ return router
159
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Example Plugin — Public API Routes
3
+ *
4
+ * Hono is SonicJS's HTTP framework. Each "route file" creates a small Hono app
5
+ * (or returns one from a factory function) and exports it. The plugin's
6
+ * `register(app)` call mounts these routes onto the root application.
7
+ *
8
+ * Route files are SYNCHRONOUS setup — they only wire up handlers, no async I/O.
9
+ * Anything that needs env/DB access goes in the handler itself (called per-request)
10
+ * or in the plugin's `onBoot` (called once per isolate warm-up).
11
+ *
12
+ * PATH NOTE: These routes are mounted at /example (NOT /api/...).
13
+ * User plugins mount after the core /api/:collection catch-all, so any
14
+ * /api/your-plugin path would be swallowed by the collection handler. See
15
+ * the plugin index.ts register() comment for the full explanation.
16
+ */
17
+
18
+ import { Hono } from 'hono'
19
+ import { DocumentRepository } from '@sonicjs-cms/core'
20
+
21
+ const MOODS_TYPE_ID = 'example'
22
+
23
+ /**
24
+ * Pick a random published mood from the DB, or null if none exist yet.
25
+ * Uses DocumentRepository (the tenant-scoped read chokepoint — R4).
26
+ */
27
+ async function getRandomMood(db: any): Promise<{ name: string; emoji: string; description: string } | null> {
28
+ try {
29
+ const repo = new DocumentRepository(db, 'default')
30
+ const moods = await repo.list({ typeId: MOODS_TYPE_ID, status: 'published', limit: 100 })
31
+ if (moods.length === 0) return null
32
+ // Workers has no Math.random() restriction — safe to use here.
33
+ const pick = moods[Math.floor(Math.random() * moods.length)]
34
+ return pick.data as { name: string; emoji: string; description: string }
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * createExampleApiRoutes — factory that returns the public API router.
42
+ *
43
+ * Factory pattern (vs. a plain exported Hono instance) lets you pass config from
44
+ * the plugin into the routes without module-level globals.
45
+ * options is passed by reference — onBoot() mutates it so handlers see live values.
46
+ */
47
+ export function createExampleApiRoutes(options: { greeting?: string; defaultName?: string } = {}): Hono {
48
+ const router = new Hono()
49
+
50
+ // ── GET /example ────────────────────────────────────────────────────────────
51
+ // Returns a JSON greeting with a randomly-selected mood from the moods collection.
52
+ // c.env holds Cloudflare bindings (DB, KV, etc.) — accessed per-request, not at setup.
53
+ router.get('/', async (c) => {
54
+ const db = (c.env as any)?.DB
55
+ const mood = db ? await getRandomMood(db) : null
56
+ const name = options.defaultName ?? 'Stranger'
57
+
58
+ return c.json({
59
+ message: `Hello, ${name}! The world is still quite cruel.`,
60
+ mood: mood ? `${mood.emoji} ${mood.name}`.trim() : null,
61
+ moodDescription: mood?.description ?? null,
62
+ timestamp: new Date().toISOString(),
63
+ plugin: 'example',
64
+ })
65
+ })
66
+
67
+ // ── GET /example/moods ──────────────────────────────────────────────────────
68
+ // Lists all published moods — must be registered BEFORE /:name so Hono's
69
+ // route-registration-order matching doesn't swallow it as a name param.
70
+ router.get('/moods', async (c) => {
71
+ const db = (c.env as any)?.DB
72
+ if (!db) return c.json({ moods: [] })
73
+
74
+ try {
75
+ const repo = new DocumentRepository(db, 'default')
76
+ const docs = await repo.list({ typeId: MOODS_TYPE_ID, status: 'published', limit: 100 })
77
+ return c.json({
78
+ moods: docs.map(d => d.data),
79
+ total: docs.length,
80
+ })
81
+ } catch {
82
+ return c.json({ moods: [], total: 0 })
83
+ }
84
+ })
85
+
86
+ // ── GET /example/:name ──────────────────────────────────────────────────────
87
+ // Personalised greeting — :name from the URL, random mood from the collection.
88
+ // Registered AFTER /moods so the literal path wins.
89
+ router.get('/:name', async (c) => {
90
+ const db = (c.env as any)?.DB
91
+ const mood = db ? await getRandomMood(db) : null
92
+ // c.req.param() reads the :name capture from the URL path.
93
+ const name = c.req.param('name')
94
+
95
+ return c.json({
96
+ message: `Hello, ${name}! The world is still quite cruel.`,
97
+ mood: mood ? `${mood.emoji} ${mood.name}`.trim() : null,
98
+ moodDescription: mood?.description ?? null,
99
+ timestamp: new Date().toISOString(),
100
+ plugin: 'example',
101
+ })
102
+ })
103
+
104
+ return router
105
+ }