From ac793d46282367233ed253a54445ed688a2d0c5b Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Fri, 8 May 2026 13:52:50 -0700 Subject: [PATCH 1/5] feat(core): explicit plugins.register API (replaces no-op autoLoad/directory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `plugins.directory` + `autoLoad` config fields were declared in the type but never wired up — bootstrap only iterated the hardcoded core plugin registry, so user plugins silently failed to mount and the starter app had to wrap the core app in its own Hono instance to attach plugin routes. Filesystem autoload is the wrong shape for this anyway: SonicJS targets Cloudflare Workers, which has no runtime `fs`, and explicit registration is what mature plugin ecosystems (Vite, Astro, Fastify, Nuxt modules) landed on — type-safe, tree-shakeable, configurable, and easy to debug. Changes: - `SonicJSConfig.plugins.register?: Plugin[]` — pass plugin builds directly - `mountPlugin()` helper sorts middleware by priority then mounts routes - User plugins mount before the `/admin/*` catch-alls so plugin admin pages are not shadowed - `directory` / `autoLoad` kept as `@deprecated` no-ops for one minor - Starter template, in-repo example app, and docs updated Co-Authored-By: Claude Opus 4.7 --- my-sonicjs-app/src/index.ts | 27 +--- packages/core/README.md | 6 +- packages/core/src/app.ts | 32 +++- packages/core/src/plugins/mount.test.ts | 148 ++++++++++++++++++ packages/core/src/plugins/mount.ts | 32 ++++ .../create-app/templates/starter/src/index.ts | 9 +- www/src/app/configuration/page.mdx | 31 ++-- 7 files changed, 240 insertions(+), 45 deletions(-) create mode 100644 packages/core/src/plugins/mount.test.ts create mode 100644 packages/core/src/plugins/mount.ts diff --git a/my-sonicjs-app/src/index.ts b/my-sonicjs-app/src/index.ts index 2a6729d06..3b3cf25a1 100644 --- a/my-sonicjs-app/src/index.ts +++ b/my-sonicjs-app/src/index.ts @@ -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' @@ -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 @@ -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) diff --git a/packages/core/README.md b/packages/core/README.md index 08048dfbf..5a465f77a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -60,6 +60,7 @@ 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: { @@ -67,8 +68,9 @@ const config: SonicJSConfig = { autoSync: true }, plugins: { - directory: './src/plugins', - autoLoad: false + register: [ + // myPlugin, + ] } } diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index fbb96e5f1..367c8a52a 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -49,6 +49,8 @@ import { eventsApiRoutes } from './plugins/core-plugins/analytics/routes/api' import cachePlugin from './plugins/cache' import { faviconSvg } from './assets/favicon' import { setAppInstance } from './services/route-metadata' +import type { Plugin } from './plugins/types' +import { mountPlugin } from './plugins/mount' // ============================================================================ // Type Definitions @@ -97,9 +99,19 @@ export interface SonicJSConfig { // Plugins configuration plugins?: { + /** User plugins to register. Each entry is the output of PluginBuilder.build(). */ + register?: Plugin[] + /** Disable all core plugin bootstrap (advanced; mostly used by stats workers). */ + disableAll?: boolean + /** + * @deprecated No-op. Pass plugins explicitly via `register`. + * Filesystem autoload is incompatible with Cloudflare Workers (no runtime fs). + */ directory?: string + /** + * @deprecated No-op. Pass plugins explicitly via `register`. + */ autoLoad?: boolean - disableAll?: boolean // Disable all plugins including core plugins } // Custom routes @@ -138,15 +150,12 @@ export type SonicJSApp = Hono<{ Bindings: Bindings; Variables: Variables }> * @example * ```typescript * import { createSonicJSApp } from '@sonicjs-cms/core' + * import myPlugin from './plugins/my-plugin' * * const app = createSonicJSApp({ - * collections: { - * directory: './src/collections', - * autoSync: true - * }, + * collections: { autoSync: true }, * plugins: { - * directory: './src/plugins', - * autoLoad: true + * register: [myPlugin] * } * }) * @@ -285,6 +294,15 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { } } + // User plugins - registered via config.plugins.register + // Mount BEFORE /admin/plugins and /admin so user admin pages aren't + // shadowed by the core admin catch-alls below. + if (config.plugins?.register) { + for (const plugin of config.plugins.register) { + mountPlugin(app, plugin) + } + } + app.route('/admin/plugins', adminPluginRoutes) app.route('/admin/logs', adminLogsRoutes) app.route('/admin', adminUsersRoutes) diff --git a/packages/core/src/plugins/mount.test.ts b/packages/core/src/plugins/mount.test.ts new file mode 100644 index 000000000..122787954 --- /dev/null +++ b/packages/core/src/plugins/mount.test.ts @@ -0,0 +1,148 @@ +/** + * Tests for the plugin mount helper used by createSonicJSApp to wire + * user-registered plugins into the Hono app. + */ + +import { describe, it, expect } from 'vitest' +import { Hono } from 'hono' +import { mountPlugin } from './mount' +import type { Plugin } from './types' + +function makePlugin(overrides: Partial = {}): Plugin { + return { + name: 'test-plugin', + version: '1.0.0', + ...overrides, + } +} + +describe('mountPlugin', () => { + it('mounts plugin routes onto the app', async () => { + const app = new Hono() + const handler = new Hono().get('/ping', (c) => c.json({ ok: true })) + + mountPlugin( + app, + makePlugin({ + routes: [{ path: '/api/test', handler }], + }) + ) + + const res = await app.request('/api/test/ping') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + }) + + it('mounts multiple routes from one plugin', async () => { + const app = new Hono() + const a = new Hono().get('/', (c) => c.text('a')) + const b = new Hono().get('/', (c) => c.text('b')) + + mountPlugin( + app, + makePlugin({ + routes: [ + { path: '/a', handler: a }, + { path: '/b', handler: b }, + ], + }) + ) + + expect(await (await app.request('/a')).text()).toBe('a') + expect(await (await app.request('/b')).text()).toBe('b') + }) + + it('applies global middleware to all routes', async () => { + const app = new Hono() + const handler = new Hono().get('/', (c) => c.text(c.get('marker') ?? 'no')) + + mountPlugin( + app, + makePlugin({ + middleware: [ + { + name: 'marker', + global: true, + handler: async (c, next) => { + c.set('marker', 'yes') + await next() + }, + }, + ], + routes: [{ path: '/x', handler }], + }) + ) + + expect(await (await app.request('/x')).text()).toBe('yes') + }) + + it('scopes middleware to routes when not global', async () => { + const app = new Hono() + const scoped = new Hono().get('/', (c) => c.text(c.get('hit') ?? 'no')) + const other = new Hono().get('/', (c) => c.text(c.get('hit') ?? 'no')) + + mountPlugin( + app, + makePlugin({ + middleware: [ + { + name: 'scoped', + routes: ['/scoped/*'], + handler: async (c, next) => { + c.set('hit', 'yes') + await next() + }, + }, + ], + routes: [ + { path: '/scoped', handler: scoped }, + { path: '/other', handler: other }, + ], + }) + ) + + expect(await (await app.request('/scoped')).text()).toBe('yes') + expect(await (await app.request('/other')).text()).toBe('no') + }) + + it('runs middleware in priority order (lower first)', async () => { + const app = new Hono() + const order: string[] = [] + const handler = new Hono().get('/', (c) => c.json(order)) + + mountPlugin( + app, + makePlugin({ + middleware: [ + { + name: 'late', + global: true, + priority: 100, + handler: async (_c, next) => { + order.push('late') + await next() + }, + }, + { + name: 'early', + global: true, + priority: 1, + handler: async (_c, next) => { + order.push('early') + await next() + }, + }, + ], + routes: [{ path: '/p', handler }], + }) + ) + + await app.request('/p') + expect(order).toEqual(['early', 'late']) + }) + + it('is a no-op for plugins with no routes or middleware', () => { + const app = new Hono() + expect(() => mountPlugin(app, makePlugin())).not.toThrow() + }) +}) diff --git a/packages/core/src/plugins/mount.ts b/packages/core/src/plugins/mount.ts new file mode 100644 index 000000000..2e10c09e6 --- /dev/null +++ b/packages/core/src/plugins/mount.ts @@ -0,0 +1,32 @@ +/** + * Mount a registered user plugin onto a Hono app. + * + * Middleware is sorted by priority (lower runs earlier). When `global` is set + * or `routes` is empty, middleware applies to '*'; otherwise each entry in + * `routes` becomes a scoped `app.use(path, handler)` registration. + * + * Routes are mounted in declaration order via `app.route(path, handler)`. + */ + +import type { Hono } from 'hono' +import type { Plugin } from './types' + +export function mountPlugin(app: Hono, plugin: Plugin): void { + const middleware = [...(plugin.middleware ?? [])].sort( + (a, b) => (a.priority ?? 100) - (b.priority ?? 100) + ) + + for (const m of middleware) { + if (m.global || !m.routes || m.routes.length === 0) { + app.use('*', m.handler) + } else { + for (const path of m.routes) { + app.use(path, m.handler) + } + } + } + + for (const route of plugin.routes ?? []) { + app.route(route.path, route.handler as any) + } +} diff --git a/packages/create-app/templates/starter/src/index.ts b/packages/create-app/templates/starter/src/index.ts index cbeca959a..85f826342 100644 --- a/packages/create-app/templates/starter/src/index.ts +++ b/packages/create-app/templates/starter/src/index.ts @@ -11,6 +11,10 @@ import type { SonicJSConfig } from '@sonicjs-cms/core' // Add new collections here after creating them in src/collections/ import blogPostsCollection from './collections/blog-posts.collection' +// Import your plugins (each is the default export from PluginBuilder.build()) +// Add new plugins here after creating them in src/plugins/ +// import myPlugin from './plugins/my-plugin' + // Register collections BEFORE creating the app // This ensures they are synced to the database on startup registerCollections([ @@ -24,8 +28,9 @@ const config: SonicJSConfig = { autoSync: true }, plugins: { - directory: './src/plugins', - autoLoad: false // Set to true to auto-load custom plugins + register: [ + // myPlugin, + ] } } diff --git a/www/src/app/configuration/page.mdx b/www/src/app/configuration/page.mdx index b264f9b75..33ffbf83c 100644 --- a/www/src/app/configuration/page.mdx +++ b/www/src/app/configuration/page.mdx @@ -42,9 +42,12 @@ interface SonicJSConfig { // Plugins configuration plugins?: { - directory?: string // Path to custom plugins - autoLoad?: boolean // Auto-load plugins from directory + register?: Plugin[] // User plugins (default export from PluginBuilder.build()) disableAll?: boolean // Disable all plugins (including core) + /** @deprecated no-op; pass plugins via `register` */ + directory?: string + /** @deprecated no-op; pass plugins via `register` */ + autoLoad?: boolean } // Custom routes @@ -76,6 +79,7 @@ interface SonicJSConfig { ```typescript import { createSonicJSApp } from '@sonicjs-cms/core' +import myPlugin from './plugins/my-plugin' const app = createSonicJSApp({ name: 'My CMS', @@ -85,8 +89,7 @@ const app = createSonicJSApp({ autoSync: true }, plugins: { - directory: './src/plugins', - autoLoad: true + register: [myPlugin] } }) @@ -363,20 +366,28 @@ Configuration for SonicJS plugins. ```typescript +import contactFormPlugin from './plugins/contact-form' +import weatherPlugin from './plugins/weather' + const app = createSonicJSApp({ plugins: { - // Directory for custom plugins - directory: './src/plugins', - - // Auto-load plugins from directory - autoLoad: true, + // Register your custom plugins (each is the default export from + // PluginBuilder.build()). Routes and middleware are mounted in order + // before the core /admin/* catch-all routes, so plugin admin pages + // are not shadowed. + register: [contactFormPlugin, weatherPlugin], - // Disable all plugins (useful for testing) + // Disable all plugins, including core (useful for testing/stats workers) disableAll: false } }) ``` +> **Note:** The previous `directory` / `autoLoad` options are no-ops kept +> only for backwards compatibility. Filesystem autoloading is incompatible +> with Cloudflare Workers (no runtime `fs`); register plugins explicitly +> via the `register` array instead. + ### Individual Plugin Settings From fa29cbb589d47cd284eccfc32d08b08d19f2cce1 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Fri, 8 May 2026 13:57:26 -0700 Subject: [PATCH 2/5] test(core): integration smoke test for createSonicJSApp + plugins.register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the full app pipeline (bootstrap, security headers, CSRF, admin auth wall, user-plugin mounting, /admin catch-alls). Critically verifies that a plugin route at /admin/plugins//* is NOT shadowed by the core /admin/plugins catch-all — a 404 there would mean the ordering broke. Also updates the doc site: - plugins/development/page.mdx Step 4 — explicit register array, not the old re-export pattern that didn't actually load anything - faq/page.mdx — adds the register snippet next to the plugin creation example so the FAQ no longer leaves users hanging the way the Discord question did Co-Authored-By: Claude Opus 4.7 --- packages/core/src/app.test.ts | 183 +++++++++++++++++++++++ www/src/app/faq/page.mdx | 10 ++ www/src/app/plugins/development/page.mdx | 19 ++- 3 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/app.test.ts diff --git a/packages/core/src/app.test.ts b/packages/core/src/app.test.ts new file mode 100644 index 000000000..05f2bae65 --- /dev/null +++ b/packages/core/src/app.test.ts @@ -0,0 +1,183 @@ +/** + * 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/ 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('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') + }) +}) diff --git a/www/src/app/faq/page.mdx b/www/src/app/faq/page.mdx index 134b9e5b9..b81c01546 100644 --- a/www/src/app/faq/page.mdx +++ b/www/src/app/faq/page.mdx @@ -284,6 +284,16 @@ export default plugin.build() +Then register it with your app in `src/index.ts`: + +```typescript +import myPlugin from './plugins/my-plugin' + +export default createSonicJSApp({ + plugins: { register: [myPlugin] } +}) +``` + Check the [Plugin Development Guide](/plugins/development) for comprehensive documentation. ### Can I use external APIs and services? diff --git a/www/src/app/plugins/development/page.mdx b/www/src/app/plugins/development/page.mdx index 1975245e6..558d19e18 100644 --- a/www/src/app/plugins/development/page.mdx +++ b/www/src/app/plugins/development/page.mdx @@ -201,13 +201,26 @@ export const myPlugin = createMyPlugin() ### Step 4: Register the Plugin -Add your plugin to your app's plugin index at `src/plugins/index.ts`: +Pass your plugin to `createSonicJSApp` via the `plugins.register` array in `src/index.ts`: ```typescript -export { myPlugin } from './my-plugin' +import { createSonicJSApp } from '@sonicjs-cms/core' +import type { SonicJSConfig } from '@sonicjs-cms/core' +import { myPlugin } from './plugins/my-plugin' + +const config: SonicJSConfig = { + collections: { autoSync: true }, + plugins: { + register: [myPlugin] + } +} + +export default createSonicJSApp(config) ``` -Your plugin will be automatically loaded when the app starts. +`createSonicJSApp` mounts each registered plugin's middleware (sorted by `priority`) and routes before the core `/admin/*` catch-all routes, so plugin admin pages are not shadowed. + +> **Why explicit registration?** SonicJS targets Cloudflare Workers, which has no runtime filesystem, so directory-based autoloading isn't possible. An explicit array is also type-safe, tree-shakeable, and lets you pass configuration to factory plugins (e.g. `register: [myPlugin({ apiKey })]`). ### Alternative: Plain Object Pattern From e4e21ab536e0565f138bbb65fa30173d071f6acf Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Fri, 8 May 2026 14:44:21 -0700 Subject: [PATCH 3/5] =?UTF-8?q?feat(core):=20merge=20furelid=20#1=20?= =?UTF-8?q?=E2=80=94=20DB-aware=20core=20plugin=20gating=20+=20expanded=20?= =?UTF-8?q?subpath=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines this branch's user-plugin work with the changes from furelid#1 (https://github.com/furelid/sonicjs/pull/1). The two PRs solved different problems and are complementary; this commit takes both. From furelid #1: - mountPluginManagerRoutes(app, plugins[], options) replaces the 9 duplicated route-mount loops in createSonicJSApp. Each core plugin's routes are now gated by a per-request guard that checks `is_active` in the plugins table — flipping the toggle in the admin UI now actually affects route resolution. - 60s TTL cache (pluginStatusCache) so the gate is one Map lookup on the hot path; one D1 read per plugin per minute amortized. - isCorePlugin callback bypasses gating for plugins with is_core === true in their manifest (database-tools, seed-data, core-cache, etc.). - plugins.enabled?: string[] for explicit allowlists when DB gating is disabled. - @sonicjs-cms/core/plugins subpath now exports aiSearchPlugin, analyticsPlugin, globalVariablesPlugin, oauthProvidersPlugin, securityAuditPlugin, OAuthService, BUILT_IN_PROVIDERS, stripePlugin, shortcodesPlugin, etc. so advanced users can wrap or compose them. - Tightens a few imports that were going through the public package re-export back to the source modules. Kept from this branch: - plugins.register?: Plugin[] for user plugins, mounted via mountPlugin() (no DB gating — if the user imported and registered it, they want it on). - @deprecated marks on plugins.directory and plugins.autoLoad. - Doc + blog post fixes. User plugins still bypass the new gating system. Adding them to mountPluginManagerRoutes with isCorePlugin: () => false is a small follow-up so the admin "disable plugin" toggle applies to user plugins too. All 1486 unit tests pass (1479 mine + furelid + 2 plugin-route-mounting + 5 createSonicJSApp integration smoke tests). Co-Authored-By: Claude Opus 4.7 --- .../middleware/middleware.permissions.test.ts | 4 +- .../plugins/plugin-route-mounting.test.ts | 48 +++ .../utils/utils.template-renderer.test.ts | 4 +- packages/core/src/app.ts | 229 +++++++---- .../services/email-renderer.ts | 4 +- .../core-plugins/otp-login-plugin/index.ts | 2 +- packages/core/src/plugins/index.ts | 66 +++- packages/core/src/plugins/plugin-manager.ts | 30 +- .../building-a-blog-with-sonicjs.mdx | 366 +++++++++--------- .../getting-started-with-sonicjs.mdx | 204 ++++++---- 10 files changed, 581 insertions(+), 376 deletions(-) create mode 100644 packages/core/src/__tests__/plugins/plugin-route-mounting.test.ts diff --git a/packages/core/src/__tests__/middleware/middleware.permissions.test.ts b/packages/core/src/__tests__/middleware/middleware.permissions.test.ts index 7ceccd7f4..9bc7c18c2 100644 --- a/packages/core/src/__tests__/middleware/middleware.permissions.test.ts +++ b/packages/core/src/__tests__/middleware/middleware.permissions.test.ts @@ -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 = () => ({ @@ -451,4 +451,4 @@ describe.skip('PermissionManager', () => { await expect(PermissionManager.getAllPermissions(mockDb)).rejects.toThrow('Query failed') }) }) -}) \ No newline at end of file +}) diff --git a/packages/core/src/__tests__/plugins/plugin-route-mounting.test.ts b/packages/core/src/__tests__/plugins/plugin-route-mounting.test.ts new file mode 100644 index 000000000..0903bf50d --- /dev/null +++ b/packages/core/src/__tests__/plugins/plugin-route-mounting.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/__tests__/utils/utils.template-renderer.test.ts b/packages/core/src/__tests__/utils/utils.template-renderer.test.ts index 6831e066a..09f7cfa21 100644 --- a/packages/core/src/__tests__/utils/utils.template-renderer.test.ts +++ b/packages/core/src/__tests__/utils/utils.template-renderer.test.ts @@ -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 @@ -498,4 +498,4 @@ describe('Template Renderer', () => { expect(result).toBe('Deep value') }) }) -}) \ No newline at end of file +}) diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index 367c8a52a..559f9e12a 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -31,6 +31,7 @@ import { bootstrapMiddleware } from './middleware/bootstrap' import { metricsMiddleware } from './middleware/metrics' import { csrfProtection } from './middleware/csrf' import { securityHeadersMiddleware } from './middleware/security-headers' +import { isPluginActive } from './middleware/plugin-middleware' import { createDatabaseToolsAdminRoutes } from './plugins/core-plugins/database-tools-plugin/admin-routes' import { createSeedDataAdminRoutes } from './plugins/core-plugins/seed-data-plugin/admin-routes' import { emailPlugin } from './plugins/core-plugins/email-plugin' @@ -42,6 +43,7 @@ import { createMagicLinkAuthPlugin } from './plugins/available/magic-link-auth' import { securityAuditPlugin } from './plugins/core-plugins/security-audit-plugin' import { securityAuditMiddleware } from './plugins/core-plugins/security-audit-plugin' import { stripePlugin } from './plugins/core-plugins/stripe-plugin' +import { globalVariablesPlugin } from './plugins/core-plugins/global-variables-plugin' import { requireAuth, requireRole } from './middleware/auth' import { pluginMenuMiddleware } from './middleware/plugin-menu' import { analyticsPlugin } from './plugins/core-plugins/analytics' @@ -51,6 +53,8 @@ import { faviconSvg } from './assets/favicon' import { setAppInstance } from './services/route-metadata' import type { Plugin } from './plugins/types' import { mountPlugin } from './plugins/mount' +import { PluginManager } from './plugins/plugin-manager' +import { getPlugin } from './plugins/manifest-registry' // ============================================================================ // Type Definitions @@ -101,8 +105,18 @@ export interface SonicJSConfig { plugins?: { /** User plugins to register. Each entry is the output of PluginBuilder.build(). */ register?: Plugin[] - /** Disable all core plugin bootstrap (advanced; mostly used by stats workers). */ + /** + * When true, disables all non-core plugins (is_core === false in manifest). + * Core plugins (is_core === true) are always active regardless of this flag. + * Takes precedence over the `enabled` list and the per-request DB active check. + */ disableAll?: boolean + /** + * Explicit allowlist of plugin names to enable. Only used when no + * per-request DB active check is wired up. Core plugins (is_core === true) + * are always enabled regardless of this list. + */ + enabled?: string[] /** * @deprecated No-op. Pass plugins explicitly via `register`. * Filesystem autoload is incompatible with Cloudflare Workers (no runtime fs). @@ -137,10 +151,120 @@ export interface SonicJSConfig { export type SonicJSApp = Hono<{ Bindings: Bindings; Variables: Variables }> +export interface PluginRouteMountOptions { + enabledPlugins?: Iterable + isPluginEnabled?: (pluginName: string, c: Context<{ Bindings: Bindings; Variables: Variables }>) => boolean | Promise + /** When true, all non-core plugins are disabled regardless of DB state or enabledPlugins list. */ + disableAll?: boolean + /** Return true for plugins that should always be mounted (i.e. is_core === true in manifest). */ + isCorePlugin?: (pluginName: string) => boolean +} + +type RoutablePlugin = { + name: string + routes?: Array<{ + path: string + handler: Hono + }> +} + +export function mountPluginManagerRoutes( + app: SonicJSApp, + plugins: RoutablePlugin[], + options: PluginRouteMountOptions = {} +): PluginManager { + const pluginManager = new PluginManager() + const enabledPlugins = new Set(options.enabledPlugins ?? []) + const isPluginEnabled = options.isPluginEnabled ?? (() => false) + const hasExplicitEnablement = options.enabledPlugins !== undefined || options.isPluginEnabled !== undefined + const shouldEnablePlugin = async ( + pluginName: string, + c: Context<{ Bindings: Bindings; Variables: Variables }> + ): Promise => { + // Core plugins (is_core === true in manifest) are always enabled. + if (options.isCorePlugin?.(pluginName)) { + return true + } + + // disableAll gates all non-core plugins. + if (options.disableAll) { + return false + } + + if (!hasExplicitEnablement) { + return true + } + + if (options.isPluginEnabled !== undefined) { + return await isPluginEnabled(pluginName, c) + } + + return enabledPlugins.has(pluginName) + } + + for (const plugin of plugins) { + pluginManager.registerPluginRoutes(plugin) + } + + for (const [pluginName, pluginApp] of pluginManager.getPluginRoutes()) { + const plugin = plugins.find(candidate => candidate.name === pluginName) + if (!plugin?.routes?.length) continue + + for (const route of plugin.routes) { + const guard = async (c: Context<{ Bindings: Bindings; Variables: Variables }>, next: () => Promise) => { + if (await shouldEnablePlugin(pluginName, c)) { + await next() + return + } + + return c.json({ error: 'Not Found', status: 404 }, 404) + } + + for (const guardedPath of [route.path, `${route.path}/*`]) { + app.use(guardedPath, guard) + } + } + + app.route('/', pluginApp) + } + + return pluginManager +} + // ============================================================================ -// Application Factory +// Plugin Active Status Cache // ============================================================================ +/** + * Module-level TTL cache for plugin active status. + * + * In Cloudflare Workers a module instance can handle many requests within its + * lifetime, so caching here avoids a D1 read on every hot-path request while + * still reflecting DB changes within a short window. + * + * **Invalidation**: status changes made via the admin UI take up to + * PLUGIN_STATUS_CACHE_TTL_MS (60 s) to propagate. For immediate effects + * (e.g. CI or integration tests) restart the Worker or reduce the TTL. + * + * **Memory**: the map is bounded by the number of distinct plugin names + * passed to mountPluginManagerRoutes (~10 core plugins), so unbounded growth + * is not a concern in practice. + */ +const PLUGIN_STATUS_CACHE_TTL_MS = 60_000 +const pluginStatusCache = new Map() + +async function isPluginActiveWithCache(db: D1Database, pluginName: string): Promise { + const now = Date.now() + const cached = pluginStatusCache.get(pluginName) + if (cached && now < cached.expiresAt) { + return cached.active + } + + const active = await isPluginActive(db, pluginName) + pluginStatusCache.set(pluginName, { active, expiresAt: now + PLUGIN_STATUS_CACHE_TTL_MS }) + return active +} + /** * Create a SonicJS application with core functionality * @@ -164,6 +288,7 @@ export type SonicJSApp = Hono<{ Bindings: Bindings; Variables: Variables }> */ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { const app = new Hono<{ Bindings: Bindings; Variables: Variables }>() + const magicLinkPlugin = createMagicLinkAuthPlugin() // Set app metadata const appVersion = config.version || getCoreVersion() @@ -215,6 +340,28 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { // Plugin dynamic menu items for admin sidebar app.use('/admin/*', pluginMenuMiddleware()) + mountPluginManagerRoutes( + app, + [ + securityAuditPlugin, + aiSearchPlugin, + oauthProvidersPlugin, + userProfilesPlugin, + otpLoginPlugin, + analyticsPlugin, + stripePlugin, + emailPlugin, + magicLinkPlugin, + globalVariablesPlugin, + ], + { + enabledPlugins: config.plugins?.enabled, + disableAll: config.plugins?.disableAll, + isCorePlugin: (name) => getPlugin(name)?.is_core === true, + isPluginEnabled: async (pluginName, c) => isPluginActiveWithCache(c.env.DB, pluginName), + } + ) + // Core routes // Routes are being imported incrementally from routes/* // Each route is tested and migrated one-by-one @@ -236,67 +383,20 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { // Security audit middleware - logs auth events (login, register, logout) app.use('/auth/*', securityAuditMiddleware()) - // Plugin routes - Security Audit (MUST be registered BEFORE admin/plugins to avoid route conflict) - if (securityAuditPlugin.routes && securityAuditPlugin.routes.length > 0) { - for (const route of securityAuditPlugin.routes) { - app.route(route.path, route.handler as any) - } - } - - // Plugin routes - AI Search (MUST be registered BEFORE admin/plugins to avoid route conflict) - // Register AI Search routes first so they take precedence over the generic /:id handler - if (aiSearchPlugin.routes && aiSearchPlugin.routes.length > 0) { - for (const route of aiSearchPlugin.routes) { - app.route(route.path, route.handler) - } - } - - // Plugin routes - Cache (dashboard and management API) - // Fixes GitHub Issue #461: Cache routes were not registered + // Cache plugin routes (dashboard + management API). The cache plugin is + // wired via its own helper because it returns a Hono sub-app from + // getRoutes() rather than declaring `routes` on a Plugin shape. app.route('/admin/cache', cachePlugin.getRoutes()) - // Plugin routes - OAuth Providers (MUST be registered BEFORE admin/plugins to avoid route conflict) - if (oauthProvidersPlugin.routes && oauthProvidersPlugin.routes.length > 0) { - for (const route of oauthProvidersPlugin.routes) { - app.route(route.path, route.handler as any) - } - } - - // Plugin routes - User Profiles - if (userProfilesPlugin.routes && userProfilesPlugin.routes.length > 0) { - for (const route of userProfilesPlugin.routes) { - app.route(route.path, route.handler as any) - } - } - - // Plugin routes - OTP Login (MUST be registered BEFORE admin/plugins to avoid route conflict) - // Register OTP Login routes first so they take precedence over the generic /:id handler - if (otpLoginPlugin.routes && otpLoginPlugin.routes.length > 0) { - for (const route of otpLoginPlugin.routes) { - app.route(route.path, route.handler as any) - } - } - - // Plugin routes - Analytics (must be before /admin/plugins catch-all) - if (analyticsPlugin.routes && analyticsPlugin.routes.length > 0) { - for (const route of analyticsPlugin.routes) { - app.route(route.path, route.handler as any) - } - } - // Public event tracking API — POST /api/events (open), GET /api/events (admin) app.route('/api/events', eventsApiRoutes) - // Plugin routes - Stripe (must be before /admin/plugins catch-all) - if (stripePlugin.routes && stripePlugin.routes.length > 0) { - for (const route of stripePlugin.routes) { - app.route(route.path, route.handler as any) - } - } - - // User plugins - registered via config.plugins.register + // User plugins - registered via config.plugins.register. // Mount BEFORE /admin/plugins and /admin so user admin pages aren't - // shadowed by the core admin catch-alls below. + // shadowed by the core admin catch-alls below. User plugins are NOT + // gated through mountPluginManagerRoutes — if you imported and + // registered them, they're on. Route them through there in the + // future if we want the admin "disable" toggle to apply. if (config.plugins?.register) { for (const plugin of config.plugins.register) { mountPlugin(app, plugin) @@ -311,21 +411,6 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { // Test cleanup routes (only for development/test environments) app.route('/', testCleanupRoutes) - // Plugin routes - Email - if (emailPlugin.routes && emailPlugin.routes.length > 0) { - for (const route of emailPlugin.routes) { - app.route(route.path, route.handler as any) - } - } - - // Plugin routes - Magic Link Auth (passwordless authentication via email links) - const magicLinkPlugin = createMagicLinkAuthPlugin() - if (magicLinkPlugin.routes && magicLinkPlugin.routes.length > 0) { - for (const route of magicLinkPlugin.routes) { - app.route(route.path, route.handler as any) - } - } - // Serve favicon app.get('/favicon.svg', (c) => { return new Response(faviconSvg, { diff --git a/packages/core/src/plugins/available/email-templates-plugin/services/email-renderer.ts b/packages/core/src/plugins/available/email-templates-plugin/services/email-renderer.ts index 17e6f7657..8660c2044 100644 --- a/packages/core/src/plugins/available/email-templates-plugin/services/email-renderer.ts +++ b/packages/core/src/plugins/available/email-templates-plugin/services/email-renderer.ts @@ -1,5 +1,5 @@ import { EmailTemplate, EmailTheme } from '../schema'; -import { renderTemplate } from '@sonicjs-cms/core'; +import { renderTemplate } from '../../../../utils'; import { marked, Renderer } from 'marked'; export interface EmailRenderResult { @@ -268,4 +268,4 @@ export class EmailTemplateRenderer { // Factory function export function createEmailRenderer(env: { DB: any }): EmailTemplateRenderer { return new EmailTemplateRenderer(env.DB); -} \ No newline at end of file +} diff --git a/packages/core/src/plugins/core-plugins/otp-login-plugin/index.ts b/packages/core/src/plugins/core-plugins/otp-login-plugin/index.ts index 9a91ce604..f6c3c6fd8 100644 --- a/packages/core/src/plugins/core-plugins/otp-login-plugin/index.ts +++ b/packages/core/src/plugins/core-plugins/otp-login-plugin/index.ts @@ -333,7 +333,7 @@ export function createOTPLoginPlugin(): Plugin { }) const customData = await getCustomData(db, user.id) - const { is_active, ...publicUser } = user + const { is_active: _isActive, ...publicUser } = user return c.json({ success: true, diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 12b6ba25a..8f2601adf 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -16,9 +16,65 @@ export { PluginManager } from './plugin-manager' // Plugin Validator export { PluginValidator } from './plugin-validator' -// Core Plugins -export { - verifyTurnstile, - createTurnstileMiddleware, - TurnstileService +// Public core plugin exports +export { + aiSearchPlugin, + AISearchService, + IndexManager, +} from './core-plugins/ai-search-plugin' +export { + analyticsPlugin, + createAnalyticsPlugin, +} from './core-plugins/analytics' +export { + globalVariablesPlugin, + createGlobalVariablesPlugin, + resolveVariables, + resolveVariablesInObject, + getVariableBlotScript, + getVariableTinyMceScript, +} from './core-plugins/global-variables-plugin' +export { + oauthProvidersPlugin, + createOAuthProvidersPlugin, +} from './core-plugins/oauth-providers' +export { + OAuthService, + BUILT_IN_PROVIDERS, +} from './core-plugins/oauth-providers/oauth-service' +export { + securityAuditPlugin, + createSecurityAuditPlugin, + SecurityAuditService, + BruteForceDetector, + securityAuditMiddleware, +} from './core-plugins/security-audit-plugin' +export { + shortcodesPlugin, + createShortcodesPlugin, + resolveShortcodes, + resolveShortcodesInObject, + registerShortcodeHandler, + getShortcodeBlotScript, + getShortcodeTinyMceScript, +} from './core-plugins/shortcodes-plugin' +export { + stripePlugin, + createStripePlugin, + SubscriptionService, + StripeAPI, + requireSubscription, +} from './core-plugins/stripe-plugin' +export { + verifyTurnstile, + createTurnstileMiddleware, + TurnstileService, + turnstilePlugin, } from './core-plugins/turnstile-plugin' +export { + userProfilesPlugin, + createUserProfilesPlugin, + defineUserProfile, + getUserProfileConfig, +} from './core-plugins/user-profiles' +export type { ProfileFieldDefinition, UserProfileConfig } from './core-plugins/user-profiles' diff --git a/packages/core/src/plugins/plugin-manager.ts b/packages/core/src/plugins/plugin-manager.ts index 0f3cd7e8d..14c21fbfb 100644 --- a/packages/core/src/plugins/plugin-manager.ts +++ b/packages/core/src/plugins/plugin-manager.ts @@ -5,7 +5,7 @@ */ import { Hono } from 'hono' -import { Plugin, PluginManager as IPluginManager, PluginRegistry, PluginConfig, PluginContext, PluginStatus, HookSystem, PluginLogger, HOOKS } from '../types' +import { Plugin, PluginManager as IPluginManager, PluginRegistry, PluginConfig, PluginContext, PluginStatus, HookSystem, PluginLogger, HOOKS, PluginRoutes } from '../types' import { PluginRegistryImpl } from './plugin-registry' import { HookSystemImpl, ScopedHookSystem } from './hook-system' import { PluginValidator } from './plugin-validator' @@ -257,21 +257,25 @@ export class PluginManager implements IPluginManager { return Array.from(this.registry.getAllStatuses().values()) } + registerPluginRoutes(plugin: { name: string; routes?: PluginRoutes[] }): void { + if (!plugin.routes) { + return + } + + const pluginApp = new Hono() + + for (const route of plugin.routes) { + pluginApp.route(route.path, route.handler) + } + + this.pluginRoutes.set(plugin.name, pluginApp) + } + /** * Register plugin extensions (routes, middleware, etc.) */ private async registerPluginExtensions(plugin: Plugin, _context: PluginContext): Promise { - // Register routes - if (plugin.routes) { - const pluginApp = new Hono() - - for (const route of plugin.routes) { - console.debug(`Registering plugin route: ${route.path}`) - pluginApp.route(route.path, route.handler) - } - - this.pluginRoutes.set(plugin.name, pluginApp) - } + this.registerPluginRoutes(plugin) // Register middleware if (plugin.middleware) { @@ -420,4 +424,4 @@ export class PluginManager implements IPluginManager { middleware: this.getPluginMiddleware().length } } -} \ No newline at end of file +} diff --git a/www/content/blog/tutorials/building-a-blog-with-sonicjs.mdx b/www/content/blog/tutorials/building-a-blog-with-sonicjs.mdx index 8519619dd..543fc134e 100644 --- a/www/content/blog/tutorials/building-a-blog-with-sonicjs.mdx +++ b/www/content/blog/tutorials/building-a-blog-with-sonicjs.mdx @@ -66,190 +66,171 @@ cd my-blog ## Step 2: Define Content Collections +A SonicJS collection is a plain object that conforms to `CollectionConfig`. The `schema` is JSON-Schema-shaped (`type: 'object'`, `properties: {...}`, `required: [...]`), and `satisfies CollectionConfig` keeps your literal types intact while still type-checking. We'll define three collections and link them with `reference` fields. + ### Authors Collection -Create `src/collections/authors.ts`: +Create `src/collections/authors.collection.ts`: ```typescript -import { defineCollection } from '@sonicjs-cms/core' +import type { CollectionConfig } from '@sonicjs-cms/core' -export const authorsCollection = defineCollection({ +export default { name: 'authors', - slug: 'authors', - fields: { - name: { - type: 'string', - required: true, - maxLength: 100, - }, - email: { - type: 'email', - required: true, - unique: true, - }, - bio: { - type: 'text', - maxLength: 500, - }, - avatar: { - type: 'media', - }, - twitter: { - type: 'string', - maxLength: 50, - }, - github: { - type: 'string', - maxLength: 50, + displayName: 'Authors', + description: 'Blog authors', + icon: '👤', + + schema: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name', required: true, maxLength: 100 }, + email: { type: 'email', title: 'Email', required: true }, + bio: { type: 'textarea', title: 'Bio', maxLength: 500 }, + avatar: { type: 'media', title: 'Avatar' }, + twitter: { type: 'string', title: 'Twitter handle', maxLength: 50 }, + github: { type: 'string', title: 'GitHub handle', maxLength: 50 }, }, + required: ['name', 'email'], }, -}) + + listFields: ['name', 'email', 'twitter'], + searchFields: ['name', 'email', 'bio'], + defaultSort: 'name', + defaultSortOrder: 'asc', + managed: true, + isActive: true, +} satisfies CollectionConfig ``` ### Categories Collection -Create `src/collections/categories.ts`: +Create `src/collections/categories.collection.ts`: ```typescript -import { defineCollection } from '@sonicjs-cms/core' +import type { CollectionConfig } from '@sonicjs-cms/core' -export const categoriesCollection = defineCollection({ +export default { name: 'categories', - slug: 'categories', - fields: { - name: { - type: 'string', - required: true, - maxLength: 50, - }, - slug: { - type: 'string', - required: true, - unique: true, - }, - description: { - type: 'text', - maxLength: 200, + displayName: 'Categories', + icon: '🏷️', + + schema: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name', required: true, maxLength: 50 }, + slug: { type: 'slug', title: 'URL Slug', required: true, maxLength: 50 }, + description: { type: 'textarea', title: 'Description', maxLength: 200 }, }, + required: ['name', 'slug'], }, -}) + + listFields: ['name', 'slug', 'description'], + searchFields: ['name', 'description'], + managed: true, + isActive: true, +} satisfies CollectionConfig ``` ### Posts Collection -Create `src/collections/posts.ts`: +Create `src/collections/posts.collection.ts`. Cross-collection links use `type: 'reference'` with a `collection:` pointer to the target collection's `name`: ```typescript -import { defineCollection } from '@sonicjs-cms/core' +import type { CollectionConfig } from '@sonicjs-cms/core' -export const postsCollection = defineCollection({ +export default { name: 'posts', - slug: 'posts', - fields: { - title: { - type: 'string', - required: true, - maxLength: 200, - }, - slug: { - type: 'string', - required: true, - unique: true, - }, - excerpt: { - type: 'text', - maxLength: 300, - }, - content: { - type: 'richtext', - required: true, - }, - featuredImage: { - type: 'media', - }, - author: { - type: 'relation', - collection: 'authors', - required: true, - }, - category: { - type: 'relation', - collection: 'categories', - }, - tags: { - type: 'array', - of: 'string', - }, - status: { - type: 'select', - options: ['draft', 'published', 'archived'], - default: 'draft', - }, - publishedAt: { - type: 'datetime', - }, - seo: { - type: 'object', - fields: { - metaTitle: { type: 'string', maxLength: 60 }, - metaDescription: { type: 'string', maxLength: 160 }, - ogImage: { type: 'media' }, + displayName: 'Posts', + icon: '📝', + + schema: { + type: 'object', + properties: { + title: { type: 'string', title: 'Title', required: true, maxLength: 200 }, + slug: { type: 'slug', title: 'URL Slug', required: true, maxLength: 200 }, + excerpt: { type: 'textarea', title: 'Excerpt', maxLength: 300 }, + content: { type: 'quill', title: 'Content', required: true }, + featuredImage: { type: 'media', title: 'Featured Image' }, + author: { + type: 'reference', + title: 'Author', + collection: 'authors', + required: true, + }, + category: { + type: 'reference', + title: 'Category', + collection: 'categories', + }, + tags: { + type: 'array', + title: 'Tags', + items: { type: 'string' }, + }, + status: { + type: 'select', + title: 'Status', + enum: ['draft', 'published', 'archived'], + enumLabels: ['Draft', 'Published', 'Archived'], + default: 'draft', + }, + publishedAt: { type: 'datetime', title: 'Published Date' }, + seo: { + type: 'object', + title: 'SEO', + properties: { + metaTitle: { type: 'string', title: 'Meta title', maxLength: 60 }, + metaDescription: { type: 'string', title: 'Meta description', maxLength: 160 }, + ogImage: { type: 'media', title: 'Social share image' }, + }, }, }, + required: ['title', 'slug', 'content', 'author'], }, -}) + + listFields: ['title', 'author', 'category', 'status', 'publishedAt'], + searchFields: ['title', 'excerpt'], + defaultSort: 'publishedAt', + defaultSortOrder: 'desc', + managed: true, + isActive: true, +} satisfies CollectionConfig ``` ## Step 3: Configure the CMS -Update `src/index.ts`: +Update `src/index.ts`. The app is created once at module top — not per request — and `createSonicJSApp` returns a Hono instance you can export directly. Cloudflare bindings (`DB`, `CACHE_KV`, `MEDIA_BUCKET`) are read from `c.env` at request time by the framework, so you don't pass them through config: ```typescript -import { Hono } from 'hono' -import { createSonicJS } from '@sonicjs-cms/core' -import { authPlugin, mediaPlugin, cachePlugin } from '@sonicjs-cms/core/plugins' -import { authorsCollection } from './collections/authors' -import { categoriesCollection } from './collections/categories' -import { postsCollection } from './collections/posts' - -type Env = { - DB: D1Database - CACHE: KVNamespace - STORAGE: R2Bucket +import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core' +import type { SonicJSConfig } from '@sonicjs-cms/core' + +import authorsCollection from './collections/authors.collection' +import categoriesCollection from './collections/categories.collection' +import postsCollection from './collections/posts.collection' + +registerCollections([ + authorsCollection, + categoriesCollection, + postsCollection, +]) + +const config: SonicJSConfig = { + collections: { autoSync: true }, + plugins: { + // Add your own plugins here. Auth, media, caching, OAuth, OTP login, + // analytics, and other core plugins are mounted automatically by + // createSonicJSApp — you don't import or register them yourself. + register: [], + }, } -const app = new Hono<{ Bindings: Env }>() - -app.use('*', async (c, next) => { - const cms = createSonicJS({ - database: c.env.DB, - cache: c.env.CACHE, - storage: c.env.STORAGE, - collections: [ - authorsCollection, - categoriesCollection, - postsCollection, - ], - plugins: [ - authPlugin(), - mediaPlugin(), - cachePlugin({ - defaultTTL: 3600, - patterns: { - '/api/content/posts': { ttl: 300 }, - '/api/content/posts/*': { ttl: 600 }, - }, - }), - ], - }) - - c.set('cms', cms) - return next() -}) - -export default app +export default createSonicJSApp(config) ``` +That's the entire app entry point. The bootstrap middleware runs once per worker instance, applies any pending core migrations, and syncs your registered collections into the `collections` table. + ## Step 4: Set Up Database Create and configure D1: @@ -259,40 +240,46 @@ Create and configure D1: wrangler d1 create my-blog-db # Create KV namespace for caching -wrangler kv:namespace create CACHE +wrangler kv namespace create CACHE_KV # Create R2 bucket for media -wrangler r2 bucket create my-blog-storage +wrangler r2 bucket create my-blog-media ``` -Update `wrangler.toml` with your resource IDs: +Update `wrangler.toml` with the resource IDs returned by each command. The binding names below match what SonicJS reads from `c.env` — don't rename them: ```toml name = "my-blog" main = "src/index.ts" compatibility_date = "2024-01-01" +compatibility_flags = ["nodejs_compat"] [[d1_databases]] binding = "DB" database_name = "my-blog-db" database_id = "your-database-id" +migrations_dir = "./node_modules/@sonicjs-cms/core/migrations" [[kv_namespaces]] -binding = "CACHE" +binding = "CACHE_KV" id = "your-kv-id" [[r2_buckets]] -binding = "STORAGE" -bucket_name = "my-blog-storage" +binding = "MEDIA_BUCKET" +bucket_name = "my-blog-media" + +[vars] +ENVIRONMENT = "development" ``` -Run migrations: +Apply the framework migrations to your local database: ```bash -npm run db:generate npm run db:migrate:local ``` +You don't generate migrations from your collection definitions — SonicJS keeps collection metadata in a `collections` table that the bootstrap middleware syncs from your `registerCollections([...])` call when `collections.autoSync` is true. + ## Step 5: Test Your API Start the development server: @@ -301,10 +288,12 @@ Start the development server: npm run dev ``` +SonicJS exposes each collection at `/api/collections//content` for both reads and writes. Public reads are open by default; writes require a JWT (sign in via `POST /auth/login` and pass the returned token as `Authorization: Bearer `). + ### Create an Author ```bash -curl -X POST http://localhost:8787/api/content/authors \ +curl -X POST http://localhost:8787/api/collections/authors/content \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{ @@ -315,10 +304,12 @@ curl -X POST http://localhost:8787/api/content/authors \ }' ``` +The response includes the generated `id` — capture it; you'll reference it from posts. + ### Create a Category ```bash -curl -X POST http://localhost:8787/api/content/categories \ +curl -X POST http://localhost:8787/api/collections/categories/content \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{ @@ -330,8 +321,10 @@ curl -X POST http://localhost:8787/api/content/categories \ ### Create a Post +Reference the author and category by their `id`: + ```bash -curl -X POST http://localhost:8787/api/content/posts \ +curl -X POST http://localhost:8787/api/collections/posts/content \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{ @@ -350,14 +343,11 @@ curl -X POST http://localhost:8787/api/content/posts \ ### Query Published Posts ```bash -# Get all published posts with author data -curl "http://localhost:8787/api/content/posts?status=published&include=author,category" - -# Search posts -curl "http://localhost:8787/api/content/posts?search=hello" +# Paginate published posts +curl "http://localhost:8787/api/collections/posts/content?status=published&limit=10&offset=0" -# Filter by category -curl "http://localhost:8787/api/content/posts?category=category-id" +# Fetch a single post by id +curl http://localhost:8787/api/content/abc123 ``` ## Step 6: Deploy to Production @@ -365,8 +355,8 @@ curl "http://localhost:8787/api/content/posts?category=category-id" Deploy your blog to Cloudflare's global network: ```bash -# Apply production migrations -npm run db:migrate:prod +# Apply migrations to your production D1 database +npm run db:migrate # Deploy npm run deploy @@ -382,34 +372,37 @@ Use any frontend framework to consume your blog API: ```typescript // lib/api.ts -const API_URL = process.env.NEXT_PUBLIC_CMS_URL +const API_URL = process.env.NEXT_PUBLIC_CMS_URL! export async function getPosts() { - const res = await fetch(`${API_URL}/api/content/posts?status=published&include=author`) - return res.json() + const res = await fetch(`${API_URL}/api/collections/posts/content?status=published`) + const json = await res.json() + return json.data ?? [] } export async function getPost(slug: string) { - const res = await fetch(`${API_URL}/api/content/posts?slug=${slug}&include=author,category`) - const data = await res.json() - return data.data[0] + const res = await fetch(`${API_URL}/api/collections/posts/content?slug=${slug}`) + const json = await res.json() + return (json.data ?? [])[0] } ``` +If you need the full author or category record alongside each post, fetch the referenced collection by id in a follow-up request and join client-side. Edge response times mean the extra round-trip is cheap. + ```tsx // app/blog/page.tsx import { getPosts } from '@/lib/api' export default async function BlogPage() { - const { data: posts } = await getPosts() + const posts = await getPosts() return (
- {posts.map((post) => ( + {posts.map((post: any) => (

{post.title}

{post.excerpt}

- By {post.author.name} + {/* `post.author` is the author record's id; resolve it separately if needed */}
))}
@@ -419,17 +412,11 @@ export default async function BlogPage() { ## Performance Tips -### Enable Aggressive Caching +### Cache at the Edge -```typescript -cachePlugin({ - defaultTTL: 3600, - patterns: { - '/api/content/posts': { ttl: 60 }, // List updates quickly - '/api/content/posts/*': { ttl: 3600 }, // Individual posts cache longer - }, -}) -``` +The core cache plugin is mounted automatically and uses `CACHE_KV` to memoize public reads. You don't import or configure it in code — the cache settings live in the admin UI under **Plugins → Cache** so non-developers can tune TTLs without redeploying. + +For very static lists, a Cache-Control header on the response is often enough; Cloudflare's edge cache handles the rest. ### Use ISR in Next.js @@ -437,16 +424,9 @@ cachePlugin({ export const revalidate = 60 // Revalidate every 60 seconds ``` -### Optimize Images - -SonicJS integrates with Cloudflare Images for automatic optimization: +### Serve Optimized Images -```typescript -mediaPlugin({ - imageOptimization: true, - variants: ['thumbnail', 'medium', 'large'], -}) -``` +Uploaded media lives in your `MEDIA_BUCKET` (R2) and is served from `/files/` with a long `Cache-Control` (one year). For automatic resizing and format conversion, hook up Cloudflare Images by setting the `IMAGES_ACCOUNT_ID` and `IMAGES_API_TOKEN` env vars; the media plugin will route through Cloudflare Images when they're present. ## Key Takeaways diff --git a/www/content/blog/tutorials/getting-started-with-sonicjs.mdx b/www/content/blog/tutorials/getting-started-with-sonicjs.mdx index 4cccb8ab8..259b41e90 100644 --- a/www/content/blog/tutorials/getting-started-with-sonicjs.mdx +++ b/www/content/blog/tutorials/getting-started-with-sonicjs.mdx @@ -103,76 +103,107 @@ my-cms/ Open `src/index.ts` to see how SonicJS is configured: ```typescript -import { Hono } from 'hono' -import { createSonicJS } from '@sonicjs-cms/core' -import { authPlugin } from '@sonicjs-cms/core/plugins' -import { postsCollection } from './collections/posts' -import { usersCollection } from './collections/users' +import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core' +import type { SonicJSConfig } from '@sonicjs-cms/core' -const app = new Hono<{ Bindings: Env }>() +import postsCollection from './collections/posts.collection' -// Initialize SonicJS -const cms = createSonicJS({ - collections: [postsCollection, usersCollection], - plugins: [authPlugin()], -}) +// Register your custom collections before creating the app so they sync +// to the database on startup. +registerCollections([postsCollection]) -// Mount the CMS -app.route('/', cms.app) +const config: SonicJSConfig = { + collections: { autoSync: true }, + plugins: { + // Add your own plugins here. Core plugins (auth, media, cache, + // analytics, OAuth, OTP login, etc.) are mounted automatically. + register: [], + }, +} -export default app +export default createSonicJSApp(config) ``` +`createSonicJSApp` returns a Hono app — export it directly. There's no extra `Hono()` wrapper, no `cms.app` accessor, and no need to register core plugins manually; they ship with the framework and mount themselves. + ## Step 3: Define Your Content Collections Collections are the heart of SonicJS. They define your content structure with full TypeScript support. -Create a new collection in `src/collections/posts.ts`: +Create a new collection in `src/collections/posts.collection.ts`: ```typescript -import { defineCollection } from '@sonicjs-cms/core' +import type { CollectionConfig } from '@sonicjs-cms/core' -export const postsCollection = defineCollection({ +export default { name: 'posts', - slug: 'posts', - fields: { - title: { - type: 'string', - required: true, - maxLength: 200, - }, - slug: { - type: 'string', - required: true, - unique: true, - }, - content: { - type: 'richtext', - required: true, - }, - excerpt: { - type: 'text', - maxLength: 500, - }, - featuredImage: { - type: 'media', - }, - author: { - type: 'relation', - collection: 'users', - }, - status: { - type: 'select', - options: ['draft', 'published', 'archived'], - default: 'draft', - }, - publishedAt: { - type: 'datetime', + displayName: 'Posts', + description: 'Blog posts and articles', + icon: '📝', + + schema: { + type: 'object', + properties: { + title: { + type: 'string', + title: 'Title', + required: true, + maxLength: 200, + }, + slug: { + type: 'slug', + title: 'URL Slug', + required: true, + maxLength: 200, + }, + excerpt: { + type: 'textarea', + title: 'Excerpt', + maxLength: 500, + }, + content: { + type: 'quill', + title: 'Content', + required: true, + }, + featuredImage: { + type: 'media', + title: 'Featured Image', + }, + author: { + type: 'string', + title: 'Author', + required: true, + }, + status: { + type: 'select', + title: 'Status', + enum: ['draft', 'published', 'archived'], + enumLabels: ['Draft', 'Published', 'Archived'], + default: 'draft', + }, + publishedAt: { + type: 'datetime', + title: 'Published Date', + }, }, + required: ['title', 'slug', 'content', 'author'], }, -}) + + // List view configuration for the admin UI + listFields: ['title', 'author', 'status', 'publishedAt'], + searchFields: ['title', 'excerpt', 'author'], + defaultSort: 'createdAt', + defaultSortOrder: 'desc', + + // Mark as a code-managed collection (vs. one created in the admin UI) + managed: true, + isActive: true, +} satisfies CollectionConfig ``` +A SonicJS collection is a plain object that conforms to the `CollectionConfig` type. Use `satisfies CollectionConfig` to get type checking without losing literal types in the schema. The `schema` is JSON-Schema-shaped (`type: 'object'`, `properties: {...}`, `required: [...]`) which is how the auto-generated REST API and admin form derive their validation. + ## Step 4: Set Up Your Database SonicJS uses Cloudflare D1, a SQLite database that runs at the edge. @@ -198,36 +229,39 @@ Add the database configuration to your `wrangler.toml`: name = "my-cms" main = "src/index.ts" compatibility_date = "2024-01-01" +compatibility_flags = ["nodejs_compat"] [[d1_databases]] binding = "DB" database_name = "my-cms-db" database_id = "your-database-id" +migrations_dir = "./node_modules/@sonicjs-cms/core/migrations" [[kv_namespaces]] -binding = "CACHE" +binding = "CACHE_KV" id = "your-kv-namespace-id" [[r2_buckets]] -binding = "STORAGE" -bucket_name = "my-cms-storage" +binding = "MEDIA_BUCKET" +bucket_name = "my-cms-media" ``` +The binding names matter — `DB`, `CACHE_KV`, and `MEDIA_BUCKET` are the names SonicJS reads from `c.env`. If you rename them in `wrangler.toml`, the framework can't find them. + ### Run Migrations -Generate and run database migrations: +The framework ships with its own migrations under `node_modules/@sonicjs-cms/core/migrations`, which is why `migrations_dir` points there. Apply them to your D1 database: ```bash -# Generate migrations from your collections -npm run db:generate - # Apply migrations locally npm run db:migrate:local -# Apply migrations to production -npm run db:migrate:prod +# Apply migrations to production (after wrangler login) +npm run db:migrate ``` +You don't generate migrations from your collection definitions — the SonicJS bootstrap middleware syncs your collection schemas into a `collections` table on first request when `collections.autoSync: true`. + ## Step 5: Start Development Launch the local development server: @@ -249,56 +283,54 @@ Open `http://localhost:8787/admin` to access the built-in admin interface. The d ## Step 6: Using the Content API -SonicJS automatically generates RESTful API endpoints for your collections. +SonicJS automatically generates RESTful API endpoints for your collections under `/api/collections//content`. + +### Read Content + +```bash +# List all posts (public, paginated) +curl http://localhost:8787/api/collections/posts/content + +# Filter and paginate +curl "http://localhost:8787/api/collections/posts/content?status=published&limit=10&offset=0" + +# Get a single content item by ID +curl http://localhost:8787/api/content/abc123 +``` ### Create Content ```bash -curl -X POST http://localhost:8787/api/content/posts \ +curl -X POST http://localhost:8787/api/collections/posts/content \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{ "title": "My First Post", "slug": "my-first-post", "content": "

Hello, world!

", - "status": "published" + "status": "published", + "author": "Me" }' ``` -### Read Content - -```bash -# Get all posts -curl http://localhost:8787/api/content/posts - -# Get a single post by ID -curl http://localhost:8787/api/content/posts/123 - -# Filter posts -curl "http://localhost:8787/api/content/posts?status=published" - -# Pagination -curl "http://localhost:8787/api/content/posts?limit=10&offset=0" -``` - ### Update Content ```bash -curl -X PUT http://localhost:8787/api/content/posts/123 \ +curl -X PUT http://localhost:8787/api/content/abc123 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ - -d '{ - "title": "Updated Title" - }' + -d '{ "title": "Updated Title" }' ``` ### Delete Content ```bash -curl -X DELETE http://localhost:8787/api/content/posts/123 \ +curl -X DELETE http://localhost:8787/api/content/abc123 \ -H "Authorization: Bearer YOUR_TOKEN" ``` +Get a token by signing in (`POST /auth/login` or `POST /auth/otp/verify`); the response includes a JWT you can pass as `Authorization: Bearer `. + ## Step 7: Deploy to Production When you're ready to go live: @@ -314,7 +346,7 @@ That's it! Your CMS is now live and running on Cloudflare's global network. ```bash # Test your production API -curl https://my-cms.your-subdomain.workers.dev/api/content/posts +curl https://my-cms.your-subdomain.workers.dev/api/collections/posts/content ``` ## Next Steps From c35467b4b59c5be7aaba2215f00155bbfc6c272f Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Fri, 8 May 2026 14:51:51 -0700 Subject: [PATCH 4/5] docs(plugins): finish blog rewrite + main README + unskip plugin e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: replace stale plugin example (createSonicJS-shaped, hooks object that doesn't match the real PluginHook[] type) with a real PluginBuilder + plugins.register snippet that compiles against today's exports. - guides/sonicjs-plugins-extending-your-cms.mdx: rewrite the registration section, npm-consumer example, and a few aside-claims to use the actual createSonicJSApp + plugins.register API. Drops fictional createSonicJS, authPlugin()/emailPlugin()/etc. factory imports, and the "drop a file in src/plugins and SonicJS picks it up" claim — Workers has no runtime fs and there is no filesystem auto-discovery. - Unskip 5 plugin e2e specs (15-plugins, 21-plugin-version-display, 28-plugin-filters-search, 29-email-plugin-settings, 36-easymde-plugin- visible). 29 was previously skipped for D1-timing flakiness in CI; worth running it again now that the merged plugin-mounting changes exercise that path differently. Co-Authored-By: Claude Opus 4.7 --- README.md | 36 +++++++--- tests/e2e/15-plugins.spec.ts | 2 +- tests/e2e/21-plugin-version-display.spec.ts | 2 +- tests/e2e/28-plugin-filters-search.spec.ts | 2 +- tests/e2e/29-email-plugin-settings.spec.ts | 7 +- tests/e2e/36-easymde-plugin-visible.spec.ts | 2 +- .../sonicjs-plugins-extending-your-cms.mdx | 70 +++++++++---------- 7 files changed, 67 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index d55ebff40..5075ba55e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tests/e2e/15-plugins.spec.ts b/tests/e2e/15-plugins.spec.ts index 3e6f65765..6cd18e93c 100644 --- a/tests/e2e/15-plugins.spec.ts +++ b/tests/e2e/15-plugins.spec.ts @@ -8,7 +8,7 @@ import { // Use environment variable for port or default to 8787 const BASE_URL = process.env.BASE_URL || 'http://localhost:8787' -test.describe.skip('Plugin Management', () => { +test.describe('Plugin Management', () => { test.beforeEach(async ({ page }) => { await ensureAdminUserExists(page) await ensureWorkflowTablesExist(page) diff --git a/tests/e2e/21-plugin-version-display.spec.ts b/tests/e2e/21-plugin-version-display.spec.ts index e69df3119..e9947f31a 100644 --- a/tests/e2e/21-plugin-version-display.spec.ts +++ b/tests/e2e/21-plugin-version-display.spec.ts @@ -7,7 +7,7 @@ const BASE_URL = process.env.BASE_URL || 'http://localhost:8787'; // Expected version for all plugins (should match manifest.json files) const EXPECTED_VERSION = '1.0.0-beta.1'; -test.describe.skip('Plugin Version Display', () => { +test.describe('Plugin Version Display', () => { test.beforeEach(async ({ page }) => { await ensureAdminUserExists(page); await loginAsAdmin(page); diff --git a/tests/e2e/28-plugin-filters-search.spec.ts b/tests/e2e/28-plugin-filters-search.spec.ts index 04b751a9f..668a9f795 100644 --- a/tests/e2e/28-plugin-filters-search.spec.ts +++ b/tests/e2e/28-plugin-filters-search.spec.ts @@ -8,7 +8,7 @@ import { // Use environment variable for port or default to 8787 const BASE_URL = process.env.BASE_URL || 'http://localhost:8787' -test.describe.skip('Plugin Filters and Search', () => { +test.describe('Plugin Filters and Search', () => { test.beforeEach(async ({ page }) => { await ensureAdminUserExists(page) await ensureWorkflowTablesExist(page) diff --git a/tests/e2e/29-email-plugin-settings.spec.ts b/tests/e2e/29-email-plugin-settings.spec.ts index 243c4a7ed..9e3bb27a0 100644 --- a/tests/e2e/29-email-plugin-settings.spec.ts +++ b/tests/e2e/29-email-plugin-settings.spec.ts @@ -1,9 +1,10 @@ import { test, expect } from '@playwright/test' import { loginAsAdmin } from './utils/test-helpers' -// TODO: These tests pass locally but fail in CI due to D1 migration timing issues -// Skipping until the CI D1 propagation issue is resolved -test.describe.skip('Email Plugin Settings', () => { +// Note: previously skipped because of D1 migration timing issues in CI. +// Unskipped to validate the merged plugin-mounting changes; if CI flakes +// on D1 propagation, retry rather than re-skipping. +test.describe('Email Plugin Settings', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page) }) diff --git a/tests/e2e/36-easymde-plugin-visible.spec.ts b/tests/e2e/36-easymde-plugin-visible.spec.ts index ab1263e5e..a7b1cdb0e 100644 --- a/tests/e2e/36-easymde-plugin-visible.spec.ts +++ b/tests/e2e/36-easymde-plugin-visible.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test' import { loginAsAdmin } from './utils/test-helpers' -test.describe.skip('EasyMDE Plugin Visibility', () => { +test.describe('EasyMDE Plugin Visibility', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page) }) diff --git a/www/content/blog/guides/sonicjs-plugins-extending-your-cms.mdx b/www/content/blog/guides/sonicjs-plugins-extending-your-cms.mdx index 303677ec7..f919175bf 100644 --- a/www/content/blog/guides/sonicjs-plugins-extending-your-cms.mdx +++ b/www/content/blog/guides/sonicjs-plugins-extending-your-cms.mdx @@ -53,7 +53,7 @@ This guide walks through the plugin system as it actually exists in `packages/co ## Why the Plugin Model Matters -A plugin in SonicJS is a plain TypeScript object that conforms to the `Plugin` interface. There is no compiled DSL, no YAML, no separate runtime. You build a plugin, hand it to `createSonicJS({ plugins: [...] })`, and it gets wired into the same Hono app that serves the rest of the CMS. +A plugin in SonicJS is a plain TypeScript object that conforms to the `Plugin` interface. There is no compiled DSL, no YAML, no separate runtime. You build a plugin, hand it to `createSonicJSApp({ plugins: { register: [...] } })`, and it gets wired into the same Hono app that serves the rest of the CMS. Three properties make the model worth using: @@ -159,7 +159,7 @@ Five things are happening: 4. **Permissions** — the `hello-world:view` permission is the contract; you'll declare it in `manifest.json` (next section). 5. **Lifecycle hooks** — `activate` and `deactivate` run when an admin toggles the plugin in the UI. -Drop this file into `src/plugins/hello-world/index.ts` and re-export it from `src/plugins/index.ts`. SonicJS picks it up at boot. +Drop this file into `src/plugins/hello-world/index.ts`. The plugin then needs to be passed to `createSonicJSApp` via `plugins.register` — see the registration section below. (There's no filesystem auto-discovery: SonicJS targets Cloudflare Workers, which has no runtime `fs`, so plugins are registered explicitly in `src/index.ts`.) ## The Manifest: Single Source of Truth @@ -196,46 +196,42 @@ A few notes that will save you time: - `defaultSettings` is what gets written into the `plugins.settings` JSON column on first install. - `is_core: true` flags a plugin as part of the platform (it cannot be uninstalled). For your code, leave it `false`. -## Registering Plugins with `createSonicJS` +## Registering Plugins with `createSonicJSApp` -Plugins compose into a SonicJS app the way middleware composes into Express: +Plugins are passed into `createSonicJSApp` via the `plugins.register` array: ```typescript // src/index.ts -import { Hono } from 'hono' -import { - createSonicJS, - authPlugin, - oauthProvidersPlugin, - otpLoginPlugin, - analyticsPlugin, - emailPlugin, -} from '@sonicjs-cms/core' +import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core' +import type { SonicJSConfig } from '@sonicjs-cms/core' + +import postsCollection from './collections/posts.collection' import { helloWorldPlugin } from './plugins/hello-world' -import { postsCollection } from './collections/posts' - -const app = new Hono<{ Bindings: Env }>() - -const cms = createSonicJS({ - collections: [postsCollection], - plugins: [ - // First-party plugins - authPlugin(), - emailPlugin(), - oauthProvidersPlugin(), - otpLoginPlugin({ codeLength: 6, codeExpiryMinutes: 10 }), - analyticsPlugin(), - - // Your own plugins - helloWorldPlugin, - ], -}) -app.route('/', cms.app) -export default app +registerCollections([postsCollection]) + +const config: SonicJSConfig = { + collections: { autoSync: true }, + plugins: { + register: [ + // Your own plugins + helloWorldPlugin, + ], + }, +} + +export default createSonicJSApp(config) ``` -Plugins load in order. If a plugin declares `dependencies: ['email-plugin']`, the registry resolves load order automatically — email is initialized first. Authentication-aware plugins like OTP login and magic link rely on this mechanism so they can refuse to start if the email plugin isn't configured. +That's the entire entry point. **You don't import or register the core plugins** — auth, email, OAuth, OTP login, analytics, media, cache, magic-link, security audit, AI search, Stripe, user profiles, and global variables are all mounted automatically by `createSonicJSApp`. Each one is gated by the admin UI's enable/disable toggle (cached for 60 s to keep the hot path fast), so you can flip them at runtime without touching code. + +Two related fields on `plugins`: + +- `register: Plugin[]` — your own plugins. Mounted before the core `/admin/*` catch-alls so plugin admin pages aren't shadowed. +- `disableAll: boolean` — turn off all non-core plugins (useful for stats workers and tests). Plugins with `is_core: true` in their manifest stay on regardless. +- `enabled: string[]` — explicit allowlist when you don't want the per-request DB toggle to drive enable/disable. + +If a plugin declares `dependencies: ['email-plugin']`, the registry resolves load order so email is bootstrapped first. Authentication-aware plugins like OTP login and magic link rely on this so they can refuse to start when their dependency isn't configured. ## Plugin with Custom Routes @@ -546,11 +542,11 @@ npm install @my-org/sonicjs-newsletter ``` ```typescript +import { createSonicJSApp } from '@sonicjs-cms/core' import { newsletterPlugin } from '@my-org/sonicjs-newsletter' -const cms = createSonicJS({ - collections: [...], - plugins: [authPlugin(), newsletterPlugin], +export default createSonicJSApp({ + plugins: { register: [newsletterPlugin] }, }) ``` From fc68020bc02117b3a053ca8dd086143f527329f1 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Fri, 8 May 2026 15:22:09 -0700 Subject: [PATCH 5/5] fix(core): plugins gated by mountPluginManagerRoutes need DB activation Re-running the plugin e2e suite against this branch (after fixing the node_modules symlink so the conductor workspace's core was actually loaded) surfaced 13 false-404s caused by furelid #1's gating logic: 1. Five plugins (email, otp-login, security-audit, core-analytics, magic-link-auth) were added to mountPluginManagerRoutes in app.ts but never added to BOOTSTRAP_PLUGIN_IDS, so their plugins-table row was never created and isPluginActive(db, name) always returned false. Result: 404 on every admin/api request to those plugins. 2. ai-search-plugin and turnstile-plugin used `name: manifest.name` ("AI Search", "Cloudflare Turnstile") for the PluginBuilder, but the gate looks up by manifest.id. The mismatch denied every request even though both manifests have is_core: true. Fixes: - Add the five missing plugins to BOOTSTRAP_PLUGIN_IDS so they're installed + activated on first boot. - Switch ai-search and turnstile to use manifest.id as the plugin name. - Add bootstrap-coverage.test.ts asserting every plugin mounted via mountPluginManagerRoutes is also bootstrapped, plus the name===id invariant for the two plugins that broke it. - Add /contact root-path regression test (plugin mounted at path: '/' with sub-path /contact must resolve, mirrors contact-form plugin). Co-Authored-By: Claude Opus 4.7 --- packages/core/src/app.test.ts | 20 ++++++ .../src/plugins/bootstrap-coverage.test.ts | 62 +++++++++++++++++++ .../core-plugins/ai-search-plugin/index.ts | 5 +- .../core-plugins/turnstile-plugin/index.ts | 5 +- .../core/src/plugins/mount-root-path.test.ts | 36 +++++++++++ .../core/src/services/plugin-bootstrap.ts | 9 +++ 6 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/plugins/bootstrap-coverage.test.ts create mode 100644 packages/core/src/plugins/mount-root-path.test.ts diff --git a/packages/core/src/app.test.ts b/packages/core/src/app.test.ts index 05f2bae65..d6f583879 100644 --- a/packages/core/src/app.test.ts +++ b/packages/core/src/app.test.ts @@ -164,6 +164,26 @@ describe('createSonicJSApp + plugins.register', () => { 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')) diff --git a/packages/core/src/plugins/bootstrap-coverage.test.ts b/packages/core/src/plugins/bootstrap-coverage.test.ts new file mode 100644 index 000000000..3aa518396 --- /dev/null +++ b/packages/core/src/plugins/bootstrap-coverage.test.ts @@ -0,0 +1,62 @@ +/** + * Regression test: every plugin mounted via createSonicJSApp's + * mountPluginManagerRoutes call must also be present in + * BOOTSTRAP_PLUGIN_IDS, or the DB-backed `is_active` gate denies its + * routes (404) on every request. + * + * Also: every plugin's `name` must equal its manifest `id`, because the + * gate (and the plugins table key) uses the id but the gate is fed the + * name from the registered Plugin. + */ + +import { describe, it, expect } from 'vitest' + +// Plugins fed to mountPluginManagerRoutes inside createSonicJSApp. +// Source: packages/core/src/app.ts. +const MOUNTED_PLUGIN_NAMES = [ + 'security-audit', + 'ai-search', + 'oauth-providers', + 'user-profiles', + 'otp-login', + 'core-analytics', + 'stripe', + 'email', + 'magic-link-auth', + 'global-variables', +] + +describe('plugin registration coverage', () => { + it('every plugin mounted in app.ts is also bootstrapped', async () => { + const file = await import('../services/plugin-bootstrap') + // BOOTSTRAP_PLUGIN_IDS isn't exported, so derive coverage from the + // service's runtime behavior: PluginBootstrapService.CORE_PLUGINS + // exposes the resolved list. We just instantiate it with a stub DB. + const stubDb: any = { prepare: () => ({ first: async () => null }) } + const svc = new file.PluginBootstrapService(stubDb) + const ids = (svc as any).CORE_PLUGINS.map((p: any) => p.id) as string[] + + for (const name of MOUNTED_PLUGIN_NAMES) { + expect( + ids, + `plugin "${name}" is mounted via mountPluginManagerRoutes in app.ts ` + + `but missing from BOOTSTRAP_PLUGIN_IDS — its admin routes will 404 ` + + `because is_active is never set in the plugins table.` + ).toContain(name) + } + }) + + it('aiSearchPlugin.name matches its manifest.id (not display name)', async () => { + const { aiSearchPlugin } = await import( + './core-plugins/ai-search-plugin' + ) + expect(aiSearchPlugin.name).toBe('ai-search') + }) + + it('turnstilePlugin.name matches its manifest.id (not display name)', async () => { + const { turnstilePlugin } = await import( + './core-plugins/turnstile-plugin' + ) + expect(turnstilePlugin.name).toBe('turnstile') + }) +}) diff --git a/packages/core/src/plugins/core-plugins/ai-search-plugin/index.ts b/packages/core/src/plugins/core-plugins/ai-search-plugin/index.ts index 9fe26d0d8..15d8e44d1 100644 --- a/packages/core/src/plugins/core-plugins/ai-search-plugin/index.ts +++ b/packages/core/src/plugins/core-plugins/ai-search-plugin/index.ts @@ -32,7 +32,10 @@ import manifest from './manifest.json' */ export const aiSearchPlugin = new PluginBuilder({ - name: manifest.name, + // Use manifest.id (not manifest.name) so the plugin's name matches its + // DB row id — mountPluginManagerRoutes' isPluginEnabled gate looks up + // `plugins.id`, and a mismatch returns 404 on every request. + name: manifest.id, version: manifest.version, description: manifest.description, author: { name: manifest.author }, diff --git a/packages/core/src/plugins/core-plugins/turnstile-plugin/index.ts b/packages/core/src/plugins/core-plugins/turnstile-plugin/index.ts index 5490dcb31..faac714bd 100644 --- a/packages/core/src/plugins/core-plugins/turnstile-plugin/index.ts +++ b/packages/core/src/plugins/core-plugins/turnstile-plugin/index.ts @@ -24,7 +24,10 @@ import manifest from './manifest.json' // Build the plugin - no custom routes, generic admin handles settings export const turnstilePlugin = new PluginBuilder({ - name: manifest.name, + // Use manifest.id (not manifest.name) so the plugin's name matches its + // DB row id — mountPluginManagerRoutes' isPluginEnabled gate looks up + // `plugins.id`, and a mismatch returns 404 on every request. + name: manifest.id, version: manifest.version, description: manifest.description, author: { name: manifest.author }, diff --git a/packages/core/src/plugins/mount-root-path.test.ts b/packages/core/src/plugins/mount-root-path.test.ts new file mode 100644 index 000000000..96ca7ab1f --- /dev/null +++ b/packages/core/src/plugins/mount-root-path.test.ts @@ -0,0 +1,36 @@ +/** + * Regression test: a plugin route with path === '/' (mounted at root) + * must still resolve when other prefixed routes are registered around it. + * + * The contact-form plugin uses this pattern: addRoute('/', publicRoutes) + * where publicRoutes has app.get('/contact', ...). Failure here = 404 on + * /contact end-to-end. + */ + +import { describe, it, expect } from 'vitest' +import { Hono } from 'hono' +import { mountPlugin } from './mount' +import type { Plugin } from './types' + +describe('mountPlugin with root path', () => { + it('resolves a /contact route when plugin is mounted at /', async () => { + const app = new Hono() + + app.route('/api', new Hono().get('/foo', (c) => c.text('foo'))) + + const publicRoutes = new Hono().get('/contact', (c) => c.text('contact')) + const plugin: Plugin = { + name: 'contact-form', + version: '1.0.0', + routes: [{ path: '/', handler: publicRoutes }], + } + mountPlugin(app, plugin) + + app.route('/admin', new Hono().get('/', (c) => c.text('admin'))) + app.notFound((c) => c.json({ error: 'Not Found' }, 404)) + + const res = await app.request('/contact') + expect(res.status).toBe(200) + expect(await res.text()).toBe('contact') + }) +}) diff --git a/packages/core/src/services/plugin-bootstrap.ts b/packages/core/src/services/plugin-bootstrap.ts index 2310f1082..4c8e7310e 100644 --- a/packages/core/src/services/plugin-bootstrap.ts +++ b/packages/core/src/services/plugin-bootstrap.ts @@ -39,6 +39,15 @@ const BOOTSTRAP_PLUGIN_IDS = [ "global-variables", "user-profiles", "stripe", + // The plugins below are mounted via mountPluginManagerRoutes in app.ts + // and their routes are gated by the DB-backed `is_active` check, so they + // must be installed + activated on first boot or every request to their + // routes returns 404. + "email", + "otp-login", + "security-audit", + "core-analytics", + "magic-link-auth", ]; function registryToCorePlugin(entry: PluginRegistryEntry): CorePlugin {