Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,23 +369,39 @@ npm run test:e2e:ui

## 🔌 Plugin Development

Create plugins for extending SonicJS functionality:
Create a plugin with the fluent `PluginBuilder` SDK and register it via `plugins.register`:

```typescript
// src/plugins/my-plugin/index.ts
import { Plugin } from '@sonicjs-cms/core'
import { Hono } from 'hono'
import { PluginBuilder } from '@sonicjs-cms/core'

export default {
const routes = new Hono().get('/ping', (c) => c.json({ ok: true }))

export default PluginBuilder.create({
name: 'my-plugin',
hooks: {
'content:beforeCreate': async (content) => {
// Plugin logic here
return content
}
}
} as Plugin
version: '1.0.0',
description: 'Example plugin',
})
.addRoute('/api/my-plugin', routes)
.addHook('content:save', async (data) => data)
.build()
```

```typescript
// src/index.ts
import { createSonicJSApp } from '@sonicjs-cms/core'
import myPlugin from './plugins/my-plugin'

export default createSonicJSApp({
plugins: { register: [myPlugin] },
})
```

Plugin routes mount before the core `/admin/*` catch-alls, so plugin admin pages aren't shadowed. Core plugins (auth, media, cache, OAuth, OTP login, analytics, etc.) are mounted automatically — you don't import or register them yourself.

See the [plugin development guide](https://sonicjs.com/plugins/development) for the full SDK reference.

## 📄 License

MIT License - see [LICENSE](LICENSE) file for details.
Expand Down
27 changes: 3 additions & 24 deletions my-sonicjs-app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* Entry point for your SonicJS headless CMS application
*/

import { Hono } from 'hono'
import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core'
import type { SonicJSConfig } from '@sonicjs-cms/core'

Expand All @@ -13,7 +12,7 @@ import blogPostsCollection from './collections/blog-posts.collection'
import pageBlocksCollection from './collections/page-blocks.collection'
import contactMessagesCollection from './collections/contact-messages.collection'

// Import plugins (manual mounting until auto-loading is implemented)
// Import custom plugins
import contactFormPlugin from './plugins/contact-form/index'

// Register all custom collections
Expand All @@ -29,28 +28,8 @@ const config: SonicJSConfig = {
autoSync: true
},
plugins: {
directory: './src/plugins',
autoLoad: false, // Set to true to auto-load custom plugins
disableAll: false, // Enable plugins
enabled: ['email', 'contact-form'] // Enable specific plugins
register: [contactFormPlugin]
}
}

// Create the core application
const coreApp = createSonicJSApp(config)

// Create main app and mount plugin routes manually
// (Plugin auto-mounting not yet implemented in core)
const app = new Hono()

// Mount plugin routes
if (contactFormPlugin.routes) {
for (const route of contactFormPlugin.routes) {
app.route(route.path, route.handler)
}
}

// Mount core app last (catch-all)
app.route('/', coreApp)

export default app
export default createSonicJSApp(config)
6 changes: 4 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,17 @@ npm install wrangler drizzle-kit # For development
// src/index.ts
import { createSonicJSApp } from '@sonicjs-cms/core'
import type { SonicJSConfig } from '@sonicjs-cms/core'
// import myPlugin from './plugins/my-plugin'

const config: SonicJSConfig = {
collections: {
directory: './src/collections',
autoSync: true
},
plugins: {
directory: './src/plugins',
autoLoad: false
register: [
// myPlugin,
]
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @ts-nocheck
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { PermissionManager, Permission, UserPermissions } from '@sonicjs-cms/core'
import { PermissionManager, type Permission, type UserPermissions } from '../../middleware'

// Helper to create mock D1Database
const createMockDb = () => ({
Expand Down Expand Up @@ -451,4 +451,4 @@ describe.skip('PermissionManager', () => {
await expect(PermissionManager.getAllPermissions(mockDb)).rejects.toThrow('Query failed')
})
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { Hono } from 'hono'
import { PluginBuilder } from '../../plugins/sdk/plugin-builder'
import { mountPluginManagerRoutes } from '../../app'
import type { Bindings, Variables } from '../../app'

describe('mountPluginManagerRoutes', () => {
it('responds for enabled plugin routes', async () => {
const pluginRoutes = new Hono().get('/', (c) => c.text('active plugin'))
const plugin = PluginBuilder.create({
name: 'active-plugin',
version: '1.0.0'
})
.addRoute('/api/active-plugin', pluginRoutes)
.build()

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
const isPluginEnabled = vi.fn(async (pluginName: string) => pluginName === 'active-plugin')

mountPluginManagerRoutes(app, [plugin], { isPluginEnabled })

const response = await app.request('http://localhost/api/active-plugin')

expect(response.status).toBe(200)
expect(await response.text()).toBe('active plugin')
expect(isPluginEnabled).toHaveBeenCalledWith('active-plugin', expect.anything())
})

it('returns 404 for inactive plugin routes', async () => {
const pluginRoutes = new Hono().get('/', (c) => c.text('inactive plugin'))
const plugin = PluginBuilder.create({
name: 'inactive-plugin',
version: '1.0.0'
})
.addRoute('/api/inactive-plugin', pluginRoutes)
.build()

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()

mountPluginManagerRoutes(app, [plugin], {
isPluginEnabled: vi.fn(async () => false)
})

const response = await app.request('http://localhost/api/inactive-plugin')

expect(response.status).toBe(404)
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { TemplateRenderer, templateRenderer, renderTemplate } from '@sonicjs-cms/core'
import { TemplateRenderer, templateRenderer, renderTemplate } from '../../utils'

describe('Template Renderer', () => {
let renderer: TemplateRenderer
Expand Down Expand Up @@ -498,4 +498,4 @@ describe('Template Renderer', () => {
expect(result).toBe('Deep value')
})
})
})
})
203 changes: 203 additions & 0 deletions packages/core/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* Integration smoke test for createSonicJSApp + plugins.register.
*
* Exercises the full pipeline (bootstrap → security headers → CSRF → admin
* auth wall → user-plugin mounting → /admin catch-alls) and verifies that
* plugin routes mount and resolve correctly, are not shadowed by the core
* /admin/* catch-alls, and that ordering between user middleware and routes
* holds end-to-end.
*/

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { Hono } from 'hono'

vi.mock('./services/collection-sync', () => ({
syncCollections: vi.fn().mockResolvedValue([]),
syncAllFormCollections: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('./services/migrations', () => ({
MigrationService: vi.fn().mockImplementation(function (this: any) {
this.runPendingMigrations = vi.fn().mockResolvedValue(undefined)
return this
}),
}))

vi.mock('./services/plugin-bootstrap', () => ({
PluginBootstrapService: vi.fn().mockImplementation(function (this: any) {
this.isBootstrapNeeded = vi.fn().mockResolvedValue(false)
this.bootstrapCorePlugins = vi.fn().mockResolvedValue(undefined)
return this
}),
}))

import { createSonicJSApp } from './app'
import { resetBootstrap } from './middleware/bootstrap'
import type { Plugin } from './plugins/types'

function createMockEnv() {
const stmt = {
first: vi.fn().mockResolvedValue(null),
all: vi.fn().mockResolvedValue({ results: [] }),
bind: vi.fn().mockReturnThis(),
run: vi.fn().mockResolvedValue({ success: true }),
}
return {
DB: { prepare: vi.fn().mockReturnValue(stmt) },
CACHE_KV: {
get: vi.fn().mockResolvedValue(null),
put: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue({ keys: [] }),
},
MEDIA_BUCKET: { get: vi.fn().mockResolvedValue(null) },
ASSETS: { fetch: vi.fn() },
JWT_SECRET: 'test-secret-for-integration-test-only',
ENVIRONMENT: 'development',
CORS_ORIGINS: '*',
}
}

async function request(app: any, path: string, init?: RequestInit) {
const env = createMockEnv()
return app.request(path, init, env)
}

describe('createSonicJSApp + plugins.register', () => {
beforeEach(() => {
resetBootstrap()
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
})

it('mounts a public plugin route and returns its response', async () => {
const handler = new Hono().get('/ping', (c) => c.json({ from: 'plugin' }))
const plugin: Plugin = {
name: 'smoke-plugin',
version: '1.0.0',
routes: [{ path: '/api/smoke', handler }],
}

const app = createSonicJSApp({
plugins: { register: [plugin] },
})

const res = await request(app, '/api/smoke/ping')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ from: 'plugin' })
})

it('plugin route at /admin/plugins/<name> is NOT shadowed by core /admin catch-all', async () => {
// The plugin claims a sub-path under /admin/plugins/. Without correct
// ordering, core's `app.route('/admin/plugins', adminPluginRoutes)` would
// match first and return its own 404. With correct ordering, the plugin
// handler runs — but because /admin/* is gated by requireAuth(), an
// unauthenticated request hits 401, which proves the route was found.
const handler = new Hono().get('/settings', (c) => c.json({ ok: true }))
const plugin: Plugin = {
name: 'admin-plugin',
version: '1.0.0',
routes: [{ path: '/admin/plugins/admin-plugin', handler }],
}

const app = createSonicJSApp({
plugins: { register: [plugin] },
})

const res = await request(app, '/admin/plugins/admin-plugin/settings')
// 401 (auth required) means the route matched and the auth middleware
// gated it. 404 would mean the catch-all swallowed it (regression).
expect([200, 302, 401]).toContain(res.status)
expect(res.status).not.toBe(404)
})

it('plugin middleware runs in priority order before plugin routes', async () => {
const order: string[] = []
const handler = new Hono().get('/check', (c) => c.json({ order }))

const plugin: Plugin = {
name: 'ordered-plugin',
version: '1.0.0',
middleware: [
{
name: 'second',
global: true,
priority: 10,
handler: async (_c, next) => {
order.push('second')
await next()
},
},
{
name: 'first',
global: true,
priority: 1,
handler: async (_c, next) => {
order.push('first')
await next()
},
},
],
routes: [{ path: '/api/order', handler }],
}

const app = createSonicJSApp({
plugins: { register: [plugin] },
})

const res = await request(app, '/api/order/check')
expect(res.status).toBe(200)
const body = (await res.json()) as { order: string[] }
expect(body.order).toEqual(['first', 'second'])
})

it('omitting plugins.register works (empty array equivalent)', async () => {
const app = createSonicJSApp({})
const res = await request(app, '/health')
expect(res.status).toBe(200)
const body = (await res.json()) as { status: string }
expect(body.status).toBe('running')
})

it('plugin mounted at root path "/" resolves a sub-path (regression for /contact)', async () => {
// Mirrors the contact-form plugin: addRoute('/', publicRoutes) where
// publicRoutes has app.get('/contact', ...). The full URL is /contact
// and it must NOT be swallowed by the core 404 handler.
const publicRoutes = new Hono().get('/contact', (c) => c.text('hello contact'))
const plugin: Plugin = {
name: 'contact-form-style',
version: '1.0.0',
routes: [{ path: '/', handler: publicRoutes }],
}

const app = createSonicJSApp({
plugins: { register: [plugin] },
})

const res = await request(app, '/contact')
expect(res.status).toBe(200)
expect(await res.text()).toBe('hello contact')
})

it('multiple registered plugins all mount', async () => {
const a = new Hono().get('/', (c) => c.text('a'))
const b = new Hono().get('/', (c) => c.text('b'))

const app = createSonicJSApp({
plugins: {
register: [
{ name: 'a', version: '1.0.0', routes: [{ path: '/api/a', handler: a }] },
{ name: 'b', version: '1.0.0', routes: [{ path: '/api/b', handler: b }] },
],
},
})

expect(await (await request(app, '/api/a')).text()).toBe('a')
expect(await (await request(app, '/api/b')).text()).toBe('b')
})
})
Loading
Loading