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/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/__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.test.ts b/packages/core/src/app.test.ts new file mode 100644 index 000000000..d6f583879 --- /dev/null +++ b/packages/core/src/app.test.ts @@ -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/ 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') + }) +}) diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index fbb96e5f1..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' @@ -49,6 +51,10 @@ 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' +import { PluginManager } from './plugins/plugin-manager' +import { getPlugin } from './plugins/manifest-registry' // ============================================================================ // Type Definitions @@ -97,9 +103,29 @@ export interface SonicJSConfig { // Plugins configuration plugins?: { + /** User plugins to register. Each entry is the output of PluginBuilder.build(). */ + register?: Plugin[] + /** + * 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). + */ directory?: string + /** + * @deprecated No-op. Pass plugins explicitly via `register`. + */ autoLoad?: boolean - disableAll?: boolean // Disable all plugins including core plugins } // Custom routes @@ -125,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 * @@ -138,15 +274,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] * } * }) * @@ -155,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() @@ -206,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 @@ -227,61 +383,23 @@ 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. + // Mount BEFORE /admin/plugins and /admin so user admin pages aren't + // 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) } } @@ -293,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/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/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/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/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/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/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/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/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 { 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/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] }, }) ``` 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 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 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