From 4da78b48a1771caa71cd73072487fcabf20c415d Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 14:20:05 -0700 Subject: [PATCH 01/30] feat(menu-plugin): data-driven admin sidebar navigation Replaces hardcoded sidebar nav with document-model-backed menu items. Plugins and admins can contribute, reorder, and toggle nav entries without code changes; four system items (Content, Collections, Users, Settings) are seeded idempotently on first boot. Key pieces: - `core-menu` plugin: registers `menu_item` document type with q_* generated columns, seeds system items via `upsertSystemItem`, reconciles plugin-declared menu entries via `reconcileMenuFromPlugins` - `menu-repository.ts`: CRUD layer (raw D1 SQL, tenant-scoped, R1/R3 compliant) - `menu.ts` middleware: post-response HTML block replacement swaps `` marker with data-driven items; falls back to hardcoded items if DB unavailable - Admin routes at `/admin/menu`: list, create/edit/delete user items, visibility toggle, move-up/move-down reorder, locked-field enforcement - `menu-icons.ts`: consolidated 30-icon map + `resolveIcon` helper - 7 real-SQLite unit tests (menu-repository.sqlite.test.ts) - E2E spec 82-menu-management.spec.ts Co-Authored-By: Claude Sonnet 4.6 --- .../services/menu-repository.sqlite.test.ts | 199 ++++++++ packages/core/src/app.ts | 6 + packages/core/src/middleware/menu.ts | 157 +++++++ .../core/src/plugins/core-plugins/index.ts | 4 +- .../plugins/core-plugins/menu-plugin/index.ts | 77 ++++ .../core-plugins/menu-plugin/manifest.json | 22 + .../menu-plugin/routes/admin-menu.ts | 210 +++++++++ .../menu-plugin/services/menu-defaults.ts | 78 ++++ .../menu-plugin/services/menu-reconcile.ts | 176 +++++++ .../menu-plugin/services/menu-repository.ts | 429 ++++++++++++++++++ .../templates/admin-menu-form.template.ts | 163 +++++++ .../templates/admin-menu-list.template.ts | 180 ++++++++ .../templates/icon-picker.template.ts | 207 +++++++++ packages/core/src/services/menu-icons.ts | 43 ++ .../layouts/admin-layout-catalyst.template.ts | 2 + tests/e2e/82-menu-management.spec.ts | 117 +++++ 16 files changed, 2069 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/__tests__/services/menu-repository.sqlite.test.ts create mode 100644 packages/core/src/middleware/menu.ts create mode 100644 packages/core/src/plugins/core-plugins/menu-plugin/index.ts create mode 100644 packages/core/src/plugins/core-plugins/menu-plugin/manifest.json create mode 100644 packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts create mode 100644 packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts create mode 100644 packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts create mode 100644 packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts create mode 100644 packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-form.template.ts create mode 100644 packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts create mode 100644 packages/core/src/plugins/core-plugins/menu-plugin/templates/icon-picker.template.ts create mode 100644 packages/core/src/services/menu-icons.ts create mode 100644 tests/e2e/82-menu-management.spec.ts diff --git a/packages/core/src/__tests__/services/menu-repository.sqlite.test.ts b/packages/core/src/__tests__/services/menu-repository.sqlite.test.ts new file mode 100644 index 000000000..3dabd1763 --- /dev/null +++ b/packages/core/src/__tests__/services/menu-repository.sqlite.test.ts @@ -0,0 +1,199 @@ +// @ts-nocheck +// Real-SQLite coverage for menu-plugin data layer. +// Tests: idempotent seeding, plugin reconcile, soft-delete guards, reorder batch. +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { createTestD1 } from '../utils/d1-sqlite' +import { + listMenuItems, + buildSidebarTree, + upsertSystemItem, + upsertPluginItem, + reorderItems, + toggleVisibility, + updateItem, + deleteItem, +} from '../../plugins/core-plugins/menu-plugin/services/menu-repository' + +const MENU_TYPE_SQL = ` + INSERT INTO document_types (id, name, display_name, schema, queryable_fields, settings, source, schema_version, is_system, is_active, created_at, updated_at) + VALUES ('menu_item','menu_item','Menu Item','{}','[]','{}','plugin',1,0,1,1,1) +` + +const MENU_FIELDS = [ + { name: 'parent', path: '$.parent', kind: 'scalar', type: 'text', column: 'q_menu_parent' }, + { name: 'visible', path: '$.visible', kind: 'scalar', type: 'boolean', column: 'q_menu_visible' }, + { name: 'source', path: '$.source', kind: 'scalar', type: 'text', column: 'q_menu_source' }, + { name: 'pluginId', path: '$.pluginId', kind: 'scalar', type: 'text', column: 'q_menu_plugin_id' }, +] + +function systemItemData(overrides = {}) { + return { + label: 'Content', + url: '/admin/content', + icon: 'document', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url', 'parent'], + ...overrides, + } +} + +describe('menu-repository — real SQLite', () => { + let db + + beforeEach(async () => { + db = createTestD1() + db.raw.prepare(MENU_TYPE_SQL).run() + await db.applyScalarSchema('menu_item', MENU_FIELDS) + }) + + afterEach(() => db.close()) + + it('upsertSystemItem is idempotent across two boots — no duplicates', async () => { + const slug = 'menu:system:content' + const data = systemItemData() + + await upsertSystemItem(db, slug, data, 10) + await upsertSystemItem(db, slug, data, 10) + + const items = await listMenuItems(db) + const matching = items.filter((i) => i.slug === slug) + expect(matching).toHaveLength(1) + }) + + it('upsertPluginItem upserts on first call then only updates url+pluginId', async () => { + const slug = 'menu:plugin:email' + const data = { + label: 'Email', + url: '/admin/email', + icon: 'envelope', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'plugin', + pluginId: 'email', + permissions: [], + lockedFields: ['url', 'parent'], + } + + await upsertPluginItem(db, slug, data, 100) + + // Simulate admin renaming label — change label in DB directly + const items1 = await listMenuItems(db) + const item = items1.find((i) => i.slug === slug) + expect(item).toBeDefined() + db.raw.prepare(`UPDATE documents SET data = json_set(data, '$.label', ?) WHERE id = ?`) + .run('Email (Custom)', item.id) + + // Re-upsert with original data — should NOT overwrite admin's custom label + await upsertPluginItem(db, slug, data, 100) + + const items2 = await listMenuItems(db) + const updated = items2.find((i) => i.slug === slug) + expect(updated.label).toBe('Email (Custom)') + expect(updated.url).toBe('/admin/email') + }) + + it('reorderItems batch-updates sort_order (R1)', async () => { + await upsertSystemItem(db, 'menu:system:content', systemItemData({ label: 'Content' }), 10) + await upsertSystemItem(db, 'menu:system:users', systemItemData({ label: 'Users', url: '/admin/users' }), 20) + + const before = await listMenuItems(db) + const [a, b] = before.map((i) => ({ id: i.id, sortOrder: i.sortOrder })) + + // Swap sort orders + await reorderItems(db, [ + { id: a.id, sortOrder: b.sortOrder, parent: null }, + { id: b.id, sortOrder: a.sortOrder, parent: null }, + ]) + + const after = await listMenuItems(db) + const aAfter = after.find((i) => i.id === a.id) + const bAfter = after.find((i) => i.id === b.id) + expect(aAfter.sortOrder).toBe(b.sortOrder) + expect(bAfter.sortOrder).toBe(a.sortOrder) + }) + + it('deleteItem blocks system source, allows user source', async () => { + await upsertSystemItem(db, 'menu:system:content', systemItemData({ source: 'system' }), 10) + const items = await listMenuItems(db) + const systemItem = items[0] + + const sysResult = await deleteItem(db, systemItem.id) + expect(sysResult.ok).toBe(false) + expect(sysResult.reason).toBeDefined() + + // Create a user item + const userSlug = 'menu:user:test' + await upsertSystemItem(db, userSlug, systemItemData({ source: 'user', lockedFields: [] }), 50) + const items2 = await listMenuItems(db) + const userItem = items2.find((i) => i.slug === userSlug) + expect(userItem).toBeDefined() + + const userResult = await deleteItem(db, userItem.id) + expect(userResult.ok).toBe(true) + + const items3 = await listMenuItems(db) + expect(items3.find((i) => i.id === userItem.id)).toBeUndefined() + }) + + it('toggleVisibility updates both column and data JSON', async () => { + await upsertSystemItem(db, 'menu:system:settings', systemItemData({ label: 'Settings', url: '/admin/settings' }), 40) + const items = await listMenuItems(db) + const item = items[0] + + await toggleVisibility(db, item.id, false) + + const row = db.raw.prepare('SELECT visible, data FROM documents WHERE id = ?').get(item.id) + expect(row.visible).toBeFalsy() + const data = JSON.parse(row.data) + expect(data.visible).toBeFalsy() + }) + + it('updateItem rejects locked fields', async () => { + await upsertSystemItem(db, 'menu:system:content', systemItemData(), 10) + const items = await listMenuItems(db) + const item = items[0] + + // Should be rejected — url is locked + const rejected = await updateItem(db, item.id, { url: '/admin/other' }, ['url', 'parent']) + expect(rejected).toBe(false) + + // Label change should succeed + const ok = await updateItem(db, item.id, { label: 'My Content' }, ['url', 'parent']) + expect(ok).toBe(true) + + const updated = (await listMenuItems(db)).find((i) => i.id === item.id) + expect(updated.label).toBe('My Content') + // URL unchanged + expect(updated.url).toBe('/admin/content') + }) + + it('buildSidebarTree nests children under parents and sorts by sortOrder', async () => { + await upsertSystemItem(db, 'menu:system:parent', systemItemData({ label: 'Parent', url: '/admin/parent' }), 10) + const all = await listMenuItems(db) + const parentId = all[0].id + + // Add child + await upsertSystemItem(db, 'menu:system:child', systemItemData({ + label: 'Child', + url: '/admin/parent/child', + parent: parentId, + source: 'user', + lockedFields: [], + }), 5) + + const items = await listMenuItems(db) + const tree = buildSidebarTree(items) + + expect(tree).toHaveLength(1) + expect(tree[0].children).toHaveLength(1) + expect(tree[0].children[0].label).toBe('Child') + }) +}) diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index a4c6953de..22cd59951 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -46,6 +46,8 @@ import { requireAuth, requireRole, requireRbac } from './middleware/auth' import { createAuth } from './auth/config' import { adminRbacRoutes } from './routes/admin-rbac' import { pluginMenuMiddleware } from './middleware/plugin-menu' +import { menuMiddleware } from './middleware/menu' +import { menuPlugin } from './plugins/core-plugins/menu-plugin' import { analyticsPlugin } from './plugins/core-plugins/analytics' import { eventsApiRoutes } from './plugins/core-plugins/analytics/routes/api' import { globalVariablesPlugin } from './plugins/core-plugins/global-variables-plugin' @@ -292,6 +294,7 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { multiTenantPlugin, lexicalEditorPlugin, versioningPlugin, + menuPlugin, ] const corePluginsAfterCatchAll = [emailPlugin, magicLinkPlugin, emailReconciliationPlugin] @@ -482,6 +485,9 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { // Plugin dynamic menu items for admin sidebar app.use('/admin/*', pluginMenuMiddleware()) + // Data-driven sidebar from menu-plugin (replaces hardcoded nav items when menu_item docs exist) + app.use('/admin/*', menuMiddleware()) + // RBAC-aware admin shell. Computes the signed-in user's effective permission // set once, then (1) redirects the dashboard landing to the first section the // user can actually reach when they lack `dashboard:read`, and (2) strips nav diff --git a/packages/core/src/middleware/menu.ts b/packages/core/src/middleware/menu.ts new file mode 100644 index 000000000..e0eca9fe3 --- /dev/null +++ b/packages/core/src/middleware/menu.ts @@ -0,0 +1,157 @@ +import type { Context, Next } from 'hono' +import type { Bindings, Variables } from '../app' +import { listMenuItems, buildSidebarTree } from '../plugins/core-plugins/menu-plugin/services/menu-repository' +import type { SidebarItem } from '../plugins/core-plugins/menu-plugin/services/menu-repository' +import { resolveIcon } from '../services/menu-icons' +import { escapeHtml } from '../utils/sanitize' + +const MARKER_START = '' +const MARKER_END = '' + +const EXTERNAL_ICON = `` + +const CHEVRON = `` + +function renderTopLevelItem(item: SidebarItem, currentPath: string): string { + const isActive = + currentPath === item.url || + (item.url !== '/admin' && currentPath.startsWith(item.url)) + const icon = resolveIcon(item.icon) + const label = escapeHtml(item.label) + const targetAttr = item.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '' + const externalAffordance = item.isExternal ? EXTERNAL_ICON : '' + + if (item.children.length > 0) { + const accordionId = `acc-${item.id.replace(/[^a-z0-9]/gi, '-')}` + const isChildActive = item.children.some( + (c) => currentPath === c.url || currentPath.startsWith(c.url), + ) + const accordionActive = isActive || isChildActive + return ` +
+ ${accordionActive ? '' : ''} + +
+ ${item.children.map((child) => renderChildItem(child, currentPath)).join('')} +
+
` + } + + return ` + + ${isActive ? '' : ''} + + ${icon} + ${label}${externalAffordance} + + ` +} + +function renderChildItem(item: SidebarItem, currentPath: string): string { + const isActive = + currentPath === item.url || + (item.url !== '/admin' && currentPath.startsWith(item.url)) + const icon = resolveIcon(item.icon) + const label = escapeHtml(item.label) + const targetAttr = item.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '' + const externalAffordance = item.isExternal ? EXTERNAL_ICON : '' + + return ` + + ${isActive ? '' : ''} + + ${icon} + ${label}${externalAffordance} + + ` +} + +const ACCORDION_SCRIPT = ` +` + +export function menuMiddleware() { + return async (c: Context<{ Bindings: Bindings; Variables: Variables }>, next: Next) => { + const path = new URL(c.req.url).pathname + if (!path.startsWith('/admin')) { + return next() + } + + await next() + + if (!c.res.headers.get('content-type')?.includes('text/html')) return + + let items: Awaited> = [] + try { + items = await listMenuItems(c.env.DB) + } catch { + return + } + + if (items.length === 0) return + + const tree = buildSidebarTree(items) + const renderedItems = tree.map((item) => renderTopLevelItem(item, path)).join('') + const navItemsHtml = renderedItems + ACCORDION_SCRIPT + + const status = c.res.status + const headers = new Headers(c.res.headers) + const html = await c.res.text() + + const startIdx = html.indexOf(MARKER_START) + const endIdx = html.indexOf(MARKER_END) + + if (startIdx !== -1 && endIdx !== -1) { + const newHtml = + html.slice(0, startIdx) + + navItemsHtml + + html.slice(endIdx + MARKER_END.length) + c.res = new Response(newHtml, { status, headers }) + } else { + c.res = new Response(html, { status, headers }) + } + } +} diff --git a/packages/core/src/plugins/core-plugins/index.ts b/packages/core/src/plugins/core-plugins/index.ts index 88910e555..e37e38c63 100644 --- a/packages/core/src/plugins/core-plugins/index.ts +++ b/packages/core/src/plugins/core-plugins/index.ts @@ -43,6 +43,7 @@ export { stripePlugin, createStripePlugin, SubscriptionService, StripeAPI, requi export { dashboardPlugin, createDashboardPlugin } from './dashboard-plugin' export { multiTenantPlugin, createMultiTenantPlugin, TenantService } from './multi-tenant-plugin' export { versioningPlugin, createVersioningPlugin } from './versioning-plugin' +export { menuPlugin, createMenuPlugin } from './menu-plugin' // Core plugins list - now imported from auto-generated registry export const CORE_PLUGIN_IDS = [ @@ -65,7 +66,8 @@ export const CORE_PLUGIN_IDS = [ 'user-profiles', 'stripe', 'multi-tenant', - 'versioning' + 'versioning', + 'core-menu' ] as const export type CorePluginNames = (typeof CORE_PLUGIN_IDS)[number] diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts new file mode 100644 index 000000000..3f7fc5d49 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts @@ -0,0 +1,77 @@ +import { definePlugin } from '../../sdk' +import { DocumentTypeRegistry } from '../../../services/document-type-registry' +import { SYSTEM_MENU_ITEMS } from './services/menu-defaults' +import { upsertSystemItem } from './services/menu-repository' +import { reconcileMenuFromPlugins } from './services/menu-reconcile' +import { adminMenuRoutes } from './routes/admin-menu' +import { z } from 'zod' +import type { D1Database } from '@cloudflare/workers-types' + +export const menuPlugin = definePlugin({ + id: 'core-menu', + version: '1.0.0', + name: 'Menu Manager', + description: 'Admin sidebar navigation manager.', + sonicjsVersionRange: '^3.0.0', + author: { name: 'SonicJS Team', email: 'team@sonicjs.com' }, + + register(app) { + app.route('/admin/menu', adminMenuRoutes as any) + }, + + async onBoot(ctx) { + const env = (ctx.env ?? {}) as Record + const db = env.DB as D1Database | undefined + if (!db) return + + try { + // 1. Register menu_item document type + const typeRegistry = new DocumentTypeRegistry(db) + await typeRegistry.register({ + id: 'menu_item', + name: 'menu_item', + displayName: 'Menu Item', + description: 'Admin sidebar navigation item', + schema: z.object({}), + pluginId: 'core-menu', + source: 'plugin', + queryableFields: [ + { name: 'parent', path: '$.parent', kind: 'scalar', type: 'text', column: 'q_menu_parent' }, + { name: 'visible', path: '$.visible', kind: 'scalar', type: 'boolean', column: 'q_menu_visible' }, + { name: 'source', path: '$.source', kind: 'scalar', type: 'text', column: 'q_menu_source' }, + { name: 'pluginId', path: '$.pluginId', kind: 'scalar', type: 'text', column: 'q_menu_plugin_id' }, + ], + settings: { + baseGrants: { admin: ['read', 'create', 'update', 'delete', 'manage'] }, + internal: true, + }, + }) + + // 2. Seed system menu items (insert-only, idempotent) + for (const item of SYSTEM_MENU_ITEMS) { + await upsertSystemItem(db, item.id, { + label: item.label, + url: item.url, + icon: item.icon, + target: item.target, + isExternal: item.isExternal, + visible: item.visible, + parent: item.parent, + source: item.source, + pluginId: item.pluginId, + permissions: [...item.permissions], + lockedFields: [...item.lockedFields], + }, item.sortOrder) + } + + // 3. Reconcile plugin-contributed menu items + await reconcileMenuFromPlugins(db) + } catch { + // DB might not be ready (pre-migration) — skip silently + } + }, +}) + +export function createMenuPlugin() { + return menuPlugin +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json new file mode 100644 index 000000000..065b63f0c --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json @@ -0,0 +1,22 @@ +{ + "id": "core-menu", + "name": "Menu Manager", + "version": "1.0.0", + "description": "Manages the admin sidebar navigation. Admins can reorder, rename, hide built-in items, and add custom links.", + "author": "SonicJS Team", + "license": "MIT", + "category": "admin", + "tags": ["menu", "navigation", "admin", "sidebar"], + "dependencies": [], + "routes": [], + "permissions": {}, + "adminMenu": { + "label": "Menu", + "icon": "bars-3", + "path": "/admin/menu", + "order": 90 + }, + "iconEmoji": "📋", + "is_core": true, + "defaultSettings": {} +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts b/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts new file mode 100644 index 000000000..d5e0b6a0b --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts @@ -0,0 +1,210 @@ +import { Hono } from 'hono' +import type { Bindings, Variables } from '../../../../app' +import { requireAuth, requireRole } from '../../../../middleware' +import { escapeHtml } from '../../../../utils/sanitize' +import { + listMenuItems, + buildSidebarTree, + updateItem, + deleteItem, + toggleVisibility, + reorderItems, +} from '../services/menu-repository' +import { nanoid } from 'nanoid' +import { D1Database } from '@cloudflare/workers-types' +// Template imports (will be created): +import { renderMenuListPage } from '../templates/admin-menu-list.template' +import { renderMenuFormPage } from '../templates/admin-menu-form.template' + +export const adminMenuRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>() + +adminMenuRoutes.use('*', requireAuth()) +adminMenuRoutes.use('*', requireRole(['admin'])) + +adminMenuRoutes.get('/', async (c) => { + const db = c.env.DB + const user = c.get('user') + const items = await listMenuItems(db) + const tree = buildSidebarTree(items) + return c.html(renderMenuListPage({ + items, + tree, + user, + currentPath: '/admin/menu', + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems'), + message: c.req.query('message'), + })) +}) + +adminMenuRoutes.get('/new', async (c) => { + const db = c.env.DB + const topLevelItems = await listMenuItems(db) + return c.html(renderMenuFormPage({ + item: null, + topLevelItems: topLevelItems.filter(i => !i.parent), + user: c.get('user'), + currentPath: '/admin/menu', + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems'), + })) +}) + +adminMenuRoutes.get('/:id', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const items = await listMenuItems(db) + const item = items.find(i => i.id === id) + if (!item) return c.notFound() + const topLevelItems = items.filter(i => !i.parent && i.id !== id) + return c.html(renderMenuFormPage({ + item, + topLevelItems, + user: c.get('user'), + currentPath: '/admin/menu', + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems'), + })) +}) + +adminMenuRoutes.post('/', async (c) => { + const db = c.env.DB + const form = await c.req.formData() + const label = String(form.get('label') ?? '').trim() + const url = String(form.get('url') ?? '').trim() + const icon = String(form.get('icon') ?? 'link').trim() + const target = form.get('target') === '_blank' ? '_blank' : '_self' + const parent = String(form.get('parent') ?? '').trim() || null + const visible = form.get('visible') !== 'false' + + if (!label) { + return c.html(renderMenuFormPage({ + item: null, + topLevelItems: [], + user: c.get('user'), + currentPath: '/admin/menu', + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems'), + error: 'Label is required', + }), 400) + } + + const now = Math.floor(Date.now() / 1000) + const id = nanoid() + const slug = `menu:user:${id}` + const isExternal = /^https?:\/\//.test(url) + const data = JSON.stringify({ + label: escapeHtml(label), + url, + icon, + target, + isExternal, + visible, + parent, + source: 'user', + pluginId: null, + permissions: [], + lockedFields: [], + }) + + const items = await listMenuItems(db) + const maxSort = items.reduce((m, i) => Math.max(m, i.sortOrder), 0) + const sortOrder = maxSort + 10 + + await db.prepare( + `INSERT INTO documents (id, root_id, type_id, type_version, version_of_id, version_number, + is_current_draft, is_published, status, parent_root_id, slug, path, title, zone, + sort_order, visible, published_at, scheduled_at, expires_at, deleted_at, + tenant_id, locale, translation_group_id, data, metadata, + owner_id, created_by, updated_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` + ).bind( + id, id, 'menu_item', 1, null, 1, + 1, 1, 'published', '', slug, null, label, null, + sortOrder, visible ? 1 : 0, now, null, null, null, + 'default', 'default', '', data, '{}', + null, c.get('user')?.userId ?? 'system', c.get('user')?.userId ?? 'system', now, now + ).run() + + return c.redirect('/admin/menu?message=Item+created') +}) + +adminMenuRoutes.put('/reorder', async (c) => { + const db = c.env.DB + const body = await c.req.json>() + if (!Array.isArray(body)) return c.json({ error: 'Invalid body' }, 400) + await reorderItems(db, body) + return c.json({ ok: true }) +}) + +adminMenuRoutes.post('/:id/visibility', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const form = await c.req.formData() + const visible = form.get('visible') === 'true' + await toggleVisibility(db, id, visible) + return c.redirect('/admin/menu?message=Visibility+updated') +}) + +adminMenuRoutes.put('/:id', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const form = await c.req.formData() + + const items = await listMenuItems(db) + const item = items.find(i => i.id === id) + if (!item) return c.json({ error: 'Not found' }, 404) + + const changes: Record = {} + if (form.has('label')) changes.label = escapeHtml(String(form.get('label')).trim()) + if (form.has('icon')) changes.icon = String(form.get('icon')).trim() + if (form.has('target')) changes.target = form.get('target') === '_blank' ? '_blank' : '_self' + if (form.has('url')) changes.url = String(form.get('url')).trim() + if (form.has('parent')) changes.parent = String(form.get('parent')).trim() || null + if (form.has('visible')) changes.visible = form.get('visible') !== 'false' + + const ok = await updateItem(db, id, changes, item.lockedFields) + if (!ok) return c.json({ error: 'Cannot modify locked fields' }, 403) + + return c.redirect('/admin/menu?message=Item+updated') +}) + +adminMenuRoutes.delete('/:id', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const result = await deleteItem(db, id) + if (!result.ok) return c.json({ error: result.reason }, 403) + return c.redirect('/admin/menu?message=Item+deleted') +}) + +adminMenuRoutes.post('/:id/move-up', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const items = await listMenuItems(db) + const idx = items.findIndex((i) => i.id === id) + const prev = items[idx - 1] + const cur = items[idx] + if (idx > 0 && cur && prev) { + await reorderItems(db, [ + { id: cur.id, sortOrder: prev.sortOrder, parent: cur.parent }, + { id: prev.id, sortOrder: cur.sortOrder, parent: prev.parent }, + ]) + } + return c.redirect('/admin/menu?message=Reordered') +}) + +adminMenuRoutes.post('/:id/move-down', async (c) => { + const db = c.env.DB + const id = c.req.param('id') + const items = await listMenuItems(db) + const idx = items.findIndex((i) => i.id === id) + const next = items[idx + 1] + const cur = items[idx] + if (idx !== -1 && idx < items.length - 1 && cur && next) { + await reorderItems(db, [ + { id: cur.id, sortOrder: next.sortOrder, parent: cur.parent }, + { id: next.id, sortOrder: cur.sortOrder, parent: next.parent }, + ]) + } + return c.redirect('/admin/menu?message=Reordered') +}) diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts new file mode 100644 index 000000000..af5b61f12 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts @@ -0,0 +1,78 @@ +export interface SystemMenuItem { + id: string + label: string + url: string + icon: string + target: '_self' + isExternal: false + visible: true + parent: null + source: 'system' + pluginId: null + permissions: [] + lockedFields: ['url', 'parent'] + sortOrder: number +} + +export const SYSTEM_MENU_ITEMS: readonly SystemMenuItem[] = [ + { + id: 'menu:system:content', + label: 'Content', + url: '/admin/content', + icon: 'document', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url', 'parent'], + sortOrder: 10, + }, + { + id: 'menu:system:collections', + label: 'Collections', + url: '/admin/collections', + icon: 'collection', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url', 'parent'], + sortOrder: 20, + }, + { + id: 'menu:system:users', + label: 'Users', + url: '/admin/users', + icon: 'users', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url', 'parent'], + sortOrder: 30, + }, + { + id: 'menu:system:settings', + label: 'Settings', + url: '/admin/settings', + icon: 'cog', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url', 'parent'], + sortOrder: 40, + }, +] as const diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts new file mode 100644 index 000000000..6a7109dc6 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts @@ -0,0 +1,176 @@ +import { D1Database } from '@cloudflare/workers-types' +import { nanoid } from 'nanoid' +import { getPluginMenu, type PluginMenuEntry } from '../../../../services/plugin-menu-singleton' +import { PLUGIN_REGISTRY, type PluginRegistryEntry } from '../../../../plugins/manifest-registry' + +interface ActivePluginEntry { + pluginId: string + label: string + url: string + icon: string + sortOrder: number +} + +async function upsertPluginRow( + db: D1Database, + slug: string, + entry: ActivePluginEntry, +): Promise { + const now = Math.floor(Date.now() / 1000) + + const existing = await db + .prepare( + `SELECT id FROM documents + WHERE slug = ? AND type_id = 'menu_item' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL`, + ) + .bind(slug) + .first<{ id: string }>() + + if (existing) { + await db + .prepare( + `UPDATE documents + SET data = json_set(data, '$.url', ?, '$.pluginId', ?), + updated_at = ? + WHERE id = ? AND tenant_id = 'default'`, + ) + .bind(entry.url, entry.pluginId, now, existing.id) + .run() + return + } + + const id = nanoid() + const data = JSON.stringify({ + label: entry.label, + url: entry.url, + icon: entry.icon, + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'plugin', + pluginId: entry.pluginId, + permissions: [], + lockedFields: ['url', 'parent'], + sortOrder: entry.sortOrder, + }) + + // 30 columns — matches documents INSERT in DocumentsService.create (R5) + await db + .prepare( + `INSERT INTO documents (id, root_id, type_id, type_version, version_of_id, version_number, + is_current_draft, is_published, status, parent_root_id, slug, path, title, zone, + sort_order, visible, published_at, scheduled_at, expires_at, deleted_at, + tenant_id, locale, translation_group_id, data, metadata, + owner_id, created_by, updated_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ) + .bind( + id, id, 'menu_item', 1, null, 1, + 1, 0, 'draft', '', slug, null, entry.label, null, + entry.sortOrder, 1, null, null, null, null, + 'default', 'default', '', data, '{}', + null, null, null, now, now, + ) + .run() +} + +async function deactivateStalePluginRows( + db: D1Database, + activePluginIds: Set, +): Promise { + const now = Math.floor(Date.now() / 1000) + + const rows = await db + .prepare( + `SELECT id, data FROM documents + WHERE type_id = 'menu_item' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL + AND json_extract(data, '$.source') = 'plugin'`, + ) + .all<{ id: string; data: string }>() + + const stale = (rows.results ?? []).filter((row) => { + let pluginId: string | undefined + try { + pluginId = JSON.parse(row.data)?.pluginId as string | undefined + } catch { + return false + } + return pluginId !== undefined && !activePluginIds.has(pluginId) + }) + + if (stale.length === 0) return + + await db.batch( + stale.map((row) => + db + .prepare( + `UPDATE documents + SET data = json_set(data, '$.visible', 0), updated_at = ? + WHERE id = ? AND tenant_id = 'default'`, + ) + .bind(now, row.id), + ), + ) +} + +export async function reconcileMenuFromPlugins(db: D1Database): Promise { + try { + // Source A: code-declared plugins via definePlugin({ menu: [...] }) + const singletonEntries = getPluginMenu() + + // Source B: manifest-registered plugins with adminMenu + const manifestEntries = (Object.values(PLUGIN_REGISTRY) as PluginRegistryEntry[]).filter( + (p): p is PluginRegistryEntry & { adminMenu: NonNullable } => + p.adminMenu !== null, + ) + + // Merge into a map keyed by pluginId; manifest entries provide the base, + // singleton entries (code-declared) win if both define the same pluginId. + const byPluginId = new Map() + + // Index manifest entries first (lower priority) + manifestEntries.forEach((p: PluginRegistryEntry, idx: number) => { + const menu = p.adminMenu! + byPluginId.set(p.id, { + pluginId: p.id, + label: menu.label, + url: menu.path, + icon: menu.icon, + sortOrder: 100 + idx * 10, + }) + }) + + // Singleton entries override (higher priority — code-declared wins) + singletonEntries.forEach((entry: PluginMenuEntry, idx: number) => { + // Derive pluginId from path: last path segment after /admin/plugins/ + // or fall back to a slugified label. The slug uniqueness is enforced by + // the caller using `menu:plugin:` so we do the same derivation. + const pathParts = entry.path.replace(/\/$/, '').split('/') + const pluginId = + pathParts[pathParts.length - 1] || + entry.label.toLowerCase().replace(/\s+/g, '-') + + byPluginId.set(pluginId, { + pluginId, + label: entry.label, + url: entry.path, + icon: entry.icon ?? 'puzzle-piece', + sortOrder: 100 + idx * 10, + }) + }) + + // Upsert each active plugin entry + for (const [pluginId, entry] of byPluginId) { + const slug = `menu:plugin:${pluginId}` + await upsertPluginRow(db, slug, entry) + } + + // Deactivate rows whose plugin is no longer in the active set + await deactivateStalePluginRows(db, new Set(byPluginId.keys())) + } catch { + // DB may not be ready at bootstrap; swallow silently + } +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts new file mode 100644 index 000000000..2df105ca0 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts @@ -0,0 +1,429 @@ +import { D1Database } from '@cloudflare/workers-types' +import { nanoid } from 'nanoid' + +export interface MenuItemData { + label: string + url: string + icon: string + target: '_self' | '_blank' + isExternal: boolean + visible: boolean + parent: string | null + source: 'system' | 'plugin' | 'user' + pluginId: string | null + permissions: string[] + lockedFields: ('url' | 'parent')[] +} + +export interface MenuItem extends MenuItemData { + id: string + slug: string + sortOrder: number + tenantId: string +} + +export interface SidebarItem { + id: string + label: string + url: string + icon: string + target: '_self' | '_blank' + isExternal: boolean + visible: boolean + source: string + sortOrder: number + children: SidebarItem[] +} + +export async function listMenuItems( + db: D1Database, + opts?: { source?: string } +): Promise { + try { + let sql = ` + SELECT id, slug, sort_order, data + FROM documents + WHERE type_id = 'menu_item' + AND tenant_id = 'default' + AND is_current_draft = 1 + AND deleted_at IS NULL + ` + const binds: unknown[] = [] + + if (opts?.source) { + sql += ` AND q_menu_source = ?` + binds.push(opts.source) + } + + sql += ` ORDER BY sort_order ASC` + + const stmt = db.prepare(sql) + const result = await (binds.length ? stmt.bind(...binds) : stmt).all<{ + id: string + slug: string + sort_order: number + data: string + }>() + + return (result.results ?? []).map((row) => { + const data: MenuItemData = JSON.parse(row.data) + return { + ...data, + id: row.id, + slug: row.slug, + sortOrder: row.sort_order, + tenantId: 'default', + } + }) + } catch { + return [] + } +} + +export function buildSidebarTree(items: MenuItem[]): SidebarItem[] { + const visible = items.filter((i) => i.visible) + visible.sort((a, b) => a.sortOrder - b.sortOrder) + + const byId = new Map() + const roots: SidebarItem[] = [] + + for (const item of visible) { + byId.set(item.id, { + id: item.id, + label: item.label, + url: item.url, + icon: item.icon, + target: item.target, + isExternal: item.isExternal, + visible: item.visible, + source: item.source, + sortOrder: item.sortOrder, + children: [], + }) + } + + for (const item of visible) { + const node = byId.get(item.id)! + if (item.parent && byId.has(item.parent)) { + byId.get(item.parent)!.children.push(node) + } else { + roots.push(node) + } + } + + for (const node of byId.values()) { + node.children.sort((a, b) => a.sortOrder - b.sortOrder) + } + + return roots +} + +export async function upsertSystemItem( + db: D1Database, + slug: string, + data: MenuItemData, + sortOrder: number +): Promise { + const now = Math.floor(Date.now() / 1000) + + const existing = await db + .prepare( + `SELECT id FROM documents WHERE slug = ? AND type_id = 'menu_item' AND tenant_id = 'default' AND is_current_draft = 1 AND deleted_at IS NULL` + ) + .bind(slug) + .first<{ id: string }>() + + if (existing) { + return + } + + const id = nanoid() + + await db + .prepare( + `INSERT INTO documents ( + id, root_id, type_id, type_version, version_of_id, version_number, + is_current_draft, is_published, status, parent_root_id, + slug, path, title, zone, sort_order, visible, + published_at, scheduled_at, expires_at, deleted_at, + tenant_id, locale, translation_group_id, + data, metadata, owner_id, created_by, updated_by, created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, ?, ? + )` + ) + .bind( + id, // id + id, // root_id + 'menu_item', // type_id + 1, // type_version + null, // version_of_id + 1, // version_number + 1, // is_current_draft + 1, // is_published + 'published', // status + '', // parent_root_id + slug, // slug + null, // path + data.label, // title + null, // zone + sortOrder, // sort_order + data.visible ? 1 : 0, // visible + now, // published_at + null, // scheduled_at + null, // expires_at + null, // deleted_at + 'default', // tenant_id + 'default', // locale + '', // translation_group_id + JSON.stringify(data), // data + '{}', // metadata + null, // owner_id + 'system', // created_by + 'system', // updated_by + now, // created_at + now // updated_at + ) + .run() +} + +export async function upsertPluginItem( + db: D1Database, + slug: string, + data: MenuItemData, + sortOrder: number +): Promise { + const now = Math.floor(Date.now() / 1000) + + const existing = await db + .prepare( + `SELECT id, data FROM documents WHERE slug = ? AND type_id = 'menu_item' AND tenant_id = 'default' AND is_current_draft = 1 AND deleted_at IS NULL` + ) + .bind(slug) + .first<{ id: string; data: string }>() + + if (existing) { + const currentData: MenuItemData = JSON.parse(existing.data) + const updatedData: MenuItemData = { + ...currentData, + url: data.url, + pluginId: data.pluginId, + } + + await db + .prepare( + `UPDATE documents SET data = ?, updated_at = ? WHERE id = ? AND tenant_id = 'default'` + ) + .bind(JSON.stringify(updatedData), now, existing.id) + .run() + + return + } + + const id = nanoid() + + await db + .prepare( + `INSERT INTO documents ( + id, root_id, type_id, type_version, version_of_id, version_number, + is_current_draft, is_published, status, parent_root_id, + slug, path, title, zone, sort_order, visible, + published_at, scheduled_at, expires_at, deleted_at, + tenant_id, locale, translation_group_id, + data, metadata, owner_id, created_by, updated_by, created_at, updated_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, ?, ? + )` + ) + .bind( + id, // id + id, // root_id + 'menu_item', // type_id + 1, // type_version + null, // version_of_id + 1, // version_number + 1, // is_current_draft + 1, // is_published + 'published', // status + '', // parent_root_id + slug, // slug + null, // path + data.label, // title + null, // zone + sortOrder, // sort_order + data.visible ? 1 : 0, // visible + now, // published_at + null, // scheduled_at + null, // expires_at + null, // deleted_at + 'default', // tenant_id + 'default', // locale + '', // translation_group_id + JSON.stringify(data), // data + '{}', // metadata + null, // owner_id + 'system', // created_by + 'system', // updated_by + now, // created_at + now // updated_at + ) + .run() +} + +export async function reorderItems( + db: D1Database, + items: Array<{ id: string; sortOrder: number; parent: string | null }> +): Promise { + if (items.length === 0) return + + const now = Math.floor(Date.now() / 1000) + + const stmts = items.map((item) => + db + .prepare( + `UPDATE documents SET + sort_order = ?, + data = json_set(data, '$.parent', ?), + updated_at = ? + WHERE id = ? AND tenant_id = 'default'` + ) + .bind(item.sortOrder, item.parent ?? null, now, item.id) + ) + + await db.batch(stmts) +} + +export async function toggleVisibility( + db: D1Database, + id: string, + visible: boolean +): Promise { + const now = Math.floor(Date.now() / 1000) + const visInt = visible ? 1 : 0 + + await db + .prepare( + `UPDATE documents SET + data = json_set(data, '$.visible', ?), + visible = ?, + updated_at = ? + WHERE id = ? AND tenant_id = 'default'` + ) + .bind(visInt, visInt, now, id) + .run() +} + +export async function updateItem( + db: D1Database, + id: string, + changes: Partial< + Pick< + MenuItemData, + 'label' | 'icon' | 'target' | 'permissions' | 'url' | 'parent' | 'visible' + > & { sortOrder?: number } + >, + lockedFields: string[] +): Promise { + const lockedKeys = new Set(lockedFields) + + for (const key of Object.keys(changes)) { + if (lockedKeys.has(key)) { + return false + } + } + + const now = Math.floor(Date.now() / 1000) + + const jsonSetParts: string[] = [] + const binds: unknown[] = [] + + const dataFields = ['label', 'icon', 'target', 'permissions', 'url', 'parent', 'visible'] as const + + for (const field of dataFields) { + if (field in changes) { + jsonSetParts.push(`'$.${field}'`, '?') + binds.push( + field === 'permissions' + ? JSON.stringify(changes[field]) + : changes[field] ?? null + ) + } + } + + const setParts: string[] = [] + + if (jsonSetParts.length > 0) { + setParts.push(`data = json_set(data, ${jsonSetParts.join(', ')})`) + } + + if ('sortOrder' in changes && changes.sortOrder !== undefined) { + setParts.push(`sort_order = ?`) + binds.push(changes.sortOrder) + } + + if ('visible' in changes && changes.visible !== undefined) { + setParts.push(`visible = ?`) + binds.push(changes.visible ? 1 : 0) + } + + if (setParts.length === 0) { + return true + } + + setParts.push(`updated_at = ?`) + binds.push(now) + binds.push(id) + + const sql = `UPDATE documents SET ${setParts.join(', ')} WHERE id = ? AND tenant_id = 'default'` + + await db.prepare(sql).bind(...binds).run() + + return true +} + +export async function deleteItem( + db: D1Database, + id: string +): Promise<{ ok: boolean; reason?: string }> { + try { + const row = await db + .prepare( + `SELECT data FROM documents WHERE id = ? AND tenant_id = 'default' AND is_current_draft = 1 AND deleted_at IS NULL` + ) + .bind(id) + .first<{ data: string }>() + + if (!row) { + return { ok: false, reason: 'Item not found' } + } + + const data: MenuItemData = JSON.parse(row.data) + + if (data.source === 'system' || data.source === 'plugin') { + return { ok: false, reason: 'Cannot delete system or plugin items' } + } + + const now = Math.floor(Date.now() / 1000) + + await db + .prepare( + `UPDATE documents SET deleted_at = ? WHERE id = ? AND tenant_id = 'default'` + ) + .bind(now, id) + .run() + + return { ok: true } + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : 'Unknown error' } + } +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-form.template.ts b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-form.template.ts new file mode 100644 index 000000000..28b309c2f --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-form.template.ts @@ -0,0 +1,163 @@ +import { renderAdminLayoutCatalyst, AdminLayoutCatalystData } from '../../../../templates/layouts/admin-layout-catalyst.template' +import { escapeHtml } from '../../../../utils/sanitize' +import type { MenuItem } from '../services/menu-repository' +import { renderIconPicker } from './icon-picker.template' + +interface MenuFormPageData { + item: MenuItem | null + topLevelItems: MenuItem[] + user?: { name?: string; email?: string; role?: string } + currentPath?: string + version?: string + dynamicMenuItems?: Array<{ label: string; path: string; icon: string }> + error?: string +} + +export function renderMenuFormPage(data: MenuFormPageData): string { + const { item, topLevelItems } = data + const isEdit = item !== null + + const errorBanner = data.error + ? `
+ ${escapeHtml(data.error)} +
` + : '' + + const parentOptions = topLevelItems + .filter((t) => !isEdit || t.id !== item?.id) + .map((t) => { + const selected = item?.parent === t.id ? 'selected' : '' + return `` + }) + .join('\n') + + const targetSelfSelected = !item || item.target === '_self' ? 'selected' : '' + const targetBlankSelected = item?.target === '_blank' ? 'selected' : '' + const visibleChecked = item ? (item.visible ? 'checked' : '') : 'checked' + + const formAttrs = isEdit + ? `hx-put="/admin/menu/${escapeHtml(item!.id)}" hx-target="body" hx-push-url="true"` + : `method="POST" action="/admin/menu"` + + const submitLabel = isEdit ? 'Save Changes' : 'Add Item' + const pageTitle = isEdit ? `Edit: ${escapeHtml(item!.label)}` : 'Add Menu Item' + + const pageContent = ` +
+ + + ${errorBanner} + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+

Icon

+ ${renderIconPicker(item?.icon ?? 'link')} +
+ +
+ + Cancel + + +
+ +
+
+
+ ` + + const layoutData: AdminLayoutCatalystData = { + title: isEdit ? 'Edit Menu Item' : 'Add Menu Item', + pageTitle: isEdit ? 'Edit Menu Item' : 'Add Menu Item', + currentPath: data.currentPath ?? '/admin/menu', + user: data.user as AdminLayoutCatalystData['user'], + version: data.version, + dynamicMenuItems: data.dynamicMenuItems, + content: pageContent + } + + return renderAdminLayoutCatalyst(layoutData) +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts new file mode 100644 index 000000000..544cd8051 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts @@ -0,0 +1,180 @@ +import { renderAdminLayoutCatalyst, AdminLayoutCatalystData } from '../../../../templates/layouts/admin-layout-catalyst.template' +import { escapeHtml } from '../../../../utils/sanitize' +import type { MenuItem, SidebarItem } from '../services/menu-repository' + +interface MenuListPageData { + items: MenuItem[] + tree: SidebarItem[] + user?: { name?: string; email?: string; role?: string } + currentPath?: string + version?: string + dynamicMenuItems?: Array<{ label: string; path: string; icon: string }> + message?: string +} + +function sourceBadge(source: string): string { + switch (source) { + case 'system': + return `system` + case 'plugin': + return `plugin` + default: + return `user` + } +} + +function iconPreview(icon: string): string { + if (!icon) return '' + if (icon.trim().startsWith('<')) { + return `${icon}` + } + return `${escapeHtml(icon)}` +} + +export function renderMenuListPage(data: MenuListPageData): string { + const messageBanner = data.message + ? `
+ ${escapeHtml(data.message)} +
` + : '' + + const rows = data.items.map((item, index) => { + const isFirst = index === 0 + const isLast = index === data.items.length - 1 + + const moveUpForm = !isFirst + ? `
+ +
` + : `` + + const moveDownForm = !isLast + ? `
+ +
` + : `` + + const deleteButton = item.source === 'user' + ? `
+ +
` + : '' + + return ` + + +
+ ${moveUpForm} + ${moveDownForm} +
+ + + ${iconPreview(item.icon)} + + + ${escapeHtml(item.label)} + ${item.parent ? `child` : ''} + + + ${escapeHtml(item.url)} + + + ${sourceBadge(item.source)} + + +
+ +
+ + +
+ + Edit + + ${deleteButton} +
+ + ` + }).join('\n') + + const emptyState = data.items.length === 0 + ? ` + + No menu items yet. Add a link to get started. + + ` + : '' + + const pageContent = ` +
+
+
+

Menu Manager

+

Manage navigation links and their order

+
+ +
+ + ${messageBanner} + +
+ + + + + + + + + + + + + + ${rows} + ${emptyState} + +
OrderIconLabelURLSourceVisibleActions
+
+
+ ` + + const layoutData: AdminLayoutCatalystData = { + title: 'Menu Manager', + pageTitle: 'Menu Manager', + currentPath: data.currentPath ?? '/admin/menu', + user: data.user as AdminLayoutCatalystData['user'], + version: data.version, + dynamicMenuItems: data.dynamicMenuItems, + content: pageContent + } + + return renderAdminLayoutCatalyst(layoutData) +} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/templates/icon-picker.template.ts b/packages/core/src/plugins/core-plugins/menu-plugin/templates/icon-picker.template.ts new file mode 100644 index 000000000..c6c4fdcab --- /dev/null +++ b/packages/core/src/plugins/core-plugins/menu-plugin/templates/icon-picker.template.ts @@ -0,0 +1,207 @@ +import { escapeHtml } from '../../../../utils/sanitize' + +const ICONS: Array<{ name: string; svg: string }> = [ + { + name: 'puzzle-piece', + svg: '' + }, + { + name: 'document', + svg: '' + }, + { + name: 'collection', + svg: '' + }, + { + name: 'users', + svg: '' + }, + { + name: 'cog', + svg: '' + }, + { + name: 'envelope', + svg: '' + }, + { + name: 'chart', + svg: '' + }, + { + name: 'bolt', + svg: '' + }, + { + name: 'sparkles', + svg: '' + }, + { + name: 'photo', + svg: '' + }, + { + name: 'lock', + svg: '' + }, + { + name: 'magnifying-glass', + svg: '' + }, + { + name: 'chart-bar', + svg: '' + }, + { + name: 'image', + svg: '' + }, + { + name: 'key', + svg: '' + }, + { + name: 'shield-check', + svg: '' + }, + { + name: 'credit-card', + svg: '' + }, + { + name: 'document-text', + svg: '' + }, + { + name: 'pencil-square', + svg: '' + }, + { + name: 'server', + svg: '' + }, + { + name: 'building-office', + svg: '' + }, + { + name: 'home', + svg: '' + }, + { + name: 'star', + svg: '' + }, + { + name: 'tag', + svg: '' + }, + { + name: 'link', + svg: '' + }, + { + name: 'external-link', + svg: '' + }, + { + name: 'eye', + svg: '' + }, + { + name: 'bars-3', + svg: '' + }, + { + name: 'arrow-right', + svg: '' + } +] + +export function renderIconPicker(selectedIcon: string): string { + const safeSelected = escapeHtml(selectedIcon) + + const iconButtons = ICONS.map(({ name, svg }) => { + const isSelected = name === selectedIcon + const selectedClasses = isSelected + ? 'ring-2 ring-cyan-500 bg-cyan-50 dark:bg-cyan-900/30' + : 'ring-1 ring-zinc-200 dark:ring-zinc-700 hover:ring-cyan-300 dark:hover:ring-cyan-600 hover:bg-zinc-50 dark:hover:bg-zinc-800' + return ` + ` + }).join('\n') + + return ` +
+ + +
+

Choose an icon

+
+ ${iconButtons} +
+
+ +
+ + +

Paste raw SVG markup to use a custom icon.

+
+
+ + + ` +} diff --git a/packages/core/src/services/menu-icons.ts b/packages/core/src/services/menu-icons.ts new file mode 100644 index 000000000..42ebfeb41 --- /dev/null +++ b/packages/core/src/services/menu-icons.ts @@ -0,0 +1,43 @@ +export const MENU_ICON_MAP: Readonly> = { + 'puzzle-piece': ``, + envelope: ``, + cog: ``, + chart: ``, + sparkles: ``, + bolt: ``, + document: ``, + lock: ``, + photo: ``, + 'magnifying-glass': ``, + 'chart-bar': ``, + image: ``, + palette: ``, + 'hand-raised': ``, + key: ``, + 'arrow-right': ``, + 'shield-check': ``, + 'credit-card': ``, + 'document-text': ``, + 'pencil-square': ``, + server: ``, + 'building-office': ``, + home: ``, + users: ``, + collection: ``, + 'bars-3': ``, + 'cog-6-tooth': ``, + star: ``, + tag: ``, + link: ``, + 'external-link': ``, + eye: ``, + 'eye-slash': ``, +} + +const FALLBACK = `` + +export function resolveIcon(nameOrSvg: string | undefined): string { + if (!nameOrSvg) return FALLBACK + if (nameOrSvg.startsWith('
+ ${allMenuItems .map((item) => { const isActive = @@ -745,6 +746,7 @@ function renderCatalystSidebar( ${pluginsSubItems}
+ diff --git a/tests/e2e/82-menu-management.spec.ts b/tests/e2e/82-menu-management.spec.ts new file mode 100644 index 000000000..ab21e7dc2 --- /dev/null +++ b/tests/e2e/82-menu-management.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test' +import { loginAsAdmin, ensureAdminUserExists } from './utils/test-helpers' + +const BASE_URL = process.env.BASE_URL || 'http://localhost:8787' + +test.describe('Menu Management', () => { + test.beforeEach(async ({ page }) => { + await ensureAdminUserExists(page) + await loginAsAdmin(page) + }) + + test('admin menu page loads and shows seeded system items', async ({ page }) => { + const resp = await page.goto(`${BASE_URL}/admin/menu`) + await page.waitForLoadState('networkidle') + + expect(resp?.status()).not.toBe(404) + expect(resp?.status()).not.toBe(500) + + const body = (await page.locator('body').textContent()) || '' + // System items should be seeded and visible + expect(body).toContain('Content') + expect(body).toContain('Collections') + expect(body).toContain('Users') + expect(body).toContain('Settings') + }) + + test('can add a custom top-level link', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/menu/new`) + await page.waitForLoadState('networkidle') + + await page.fill('input[name="label"]', 'Resources') + await page.fill('input[name="url"]', 'https://docs.sonicjs.com') + + // Set target blank + await page.selectOption('select[name="target"]', '_blank') + + await page.click('button[type="submit"]') + await page.waitForLoadState('networkidle') + + // Should redirect back to list + expect(page.url()).toContain('/admin/menu') + const body = (await page.locator('body').textContent()) || '' + expect(body).toContain('Resources') + }) + + test('cannot delete a system menu item via API', async ({ page, request }) => { + // Get list to find a system item ID + await page.goto(`${BASE_URL}/admin/menu`) + await page.waitForLoadState('networkidle') + + // Try to delete a system item via API — should 403 + const sessionCookies = await page.context().cookies() + const cookieHeader = sessionCookies.map((c) => `${c.name}=${c.value}`).join('; ') + + // Get CSRF token from cookie + const csrfCookie = sessionCookies.find((c) => c.name === 'csrf_token') + const csrfToken = csrfCookie?.value || '' + + // Find a system item id from the page HTML + const html = await page.content() + const systemIdMatch = html.match(/\/admin\/menu\/([^"']+)\/move-up/) + if (systemIdMatch) { + const itemId = systemIdMatch[1] + const deleteResp = await request.delete(`${BASE_URL}/admin/menu/${itemId}`, { + headers: { + cookie: cookieHeader, + 'x-csrf-token': csrfToken, + }, + }) + // System items return 403 + expect([403, 302]).toContain(deleteResp.status()) + } + }) + + test('hide and show a menu item', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/menu`) + await page.waitForLoadState('networkidle') + + // Find the visibility toggle form for Settings + const settingsRow = page.locator('tr', { hasText: 'Settings' }).first() + const visibleCheckbox = settingsRow.locator('input[name="visible"]') + + if (await visibleCheckbox.count() > 0) { + // Toggle visibility + const toggleForm = settingsRow.locator('form').first() + await toggleForm.evaluate((f: HTMLFormElement) => f.submit()) + await page.waitForLoadState('networkidle') + + const body = (await page.locator('body').textContent()) || '' + expect(body).toContain('Visibility updated') + } + }) + + test('sidebar shows data-driven items after menu plugin seeds', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/content`) + await page.waitForLoadState('networkidle') + + const nav = page.locator('nav') + const navText = await nav.textContent() || '' + + // The data-driven sidebar should contain the seeded system items + expect(navText).toContain('Content') + }) + + test('add item form rejects empty label', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/menu/new`) + await page.waitForLoadState('networkidle') + + // Submit without label + await page.fill('input[name="url"]', '/some/path') + await page.click('button[type="submit"]') + await page.waitForLoadState('networkidle') + + const body = (await page.locator('body').textContent()) || '' + expect(body).toContain('Label is required') + }) +}) From 8d7de3b726b2b912c4e23aa95d12ec5a9cf90251 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 14:35:24 -0700 Subject: [PATCH 02/30] refactor(menu-plugin): make opt-in, remove from core plugin list menuPlugin is no longer auto-registered. Developers enable it by adding it to config.plugins.register in their app entry point. menuMiddleware is now mounted inside the plugin's register() so it only runs when the plugin is active. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/app.ts | 6 ------ packages/core/src/plugins/core-plugins/index.ts | 1 - packages/core/src/plugins/core-plugins/menu-plugin/index.ts | 3 +++ 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index 22cd59951..a4c6953de 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -46,8 +46,6 @@ import { requireAuth, requireRole, requireRbac } from './middleware/auth' import { createAuth } from './auth/config' import { adminRbacRoutes } from './routes/admin-rbac' import { pluginMenuMiddleware } from './middleware/plugin-menu' -import { menuMiddleware } from './middleware/menu' -import { menuPlugin } from './plugins/core-plugins/menu-plugin' import { analyticsPlugin } from './plugins/core-plugins/analytics' import { eventsApiRoutes } from './plugins/core-plugins/analytics/routes/api' import { globalVariablesPlugin } from './plugins/core-plugins/global-variables-plugin' @@ -294,7 +292,6 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { multiTenantPlugin, lexicalEditorPlugin, versioningPlugin, - menuPlugin, ] const corePluginsAfterCatchAll = [emailPlugin, magicLinkPlugin, emailReconciliationPlugin] @@ -485,9 +482,6 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { // Plugin dynamic menu items for admin sidebar app.use('/admin/*', pluginMenuMiddleware()) - // Data-driven sidebar from menu-plugin (replaces hardcoded nav items when menu_item docs exist) - app.use('/admin/*', menuMiddleware()) - // RBAC-aware admin shell. Computes the signed-in user's effective permission // set once, then (1) redirects the dashboard landing to the first section the // user can actually reach when they lack `dashboard:read`, and (2) strips nav diff --git a/packages/core/src/plugins/core-plugins/index.ts b/packages/core/src/plugins/core-plugins/index.ts index e37e38c63..9387651c6 100644 --- a/packages/core/src/plugins/core-plugins/index.ts +++ b/packages/core/src/plugins/core-plugins/index.ts @@ -67,7 +67,6 @@ export const CORE_PLUGIN_IDS = [ 'stripe', 'multi-tenant', 'versioning', - 'core-menu' ] as const export type CorePluginNames = (typeof CORE_PLUGIN_IDS)[number] diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts index 3f7fc5d49..a4a6085e2 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts @@ -4,6 +4,7 @@ import { SYSTEM_MENU_ITEMS } from './services/menu-defaults' import { upsertSystemItem } from './services/menu-repository' import { reconcileMenuFromPlugins } from './services/menu-reconcile' import { adminMenuRoutes } from './routes/admin-menu' +import { menuMiddleware } from '../../../middleware/menu' import { z } from 'zod' import type { D1Database } from '@cloudflare/workers-types' @@ -16,6 +17,8 @@ export const menuPlugin = definePlugin({ author: { name: 'SonicJS Team', email: 'team@sonicjs.com' }, register(app) { + // Mount sidebar replacement middleware only when this plugin is active + app.use('/admin/*', menuMiddleware()) app.route('/admin/menu', adminMenuRoutes as any) }, From 3e32b52883f2c0e0d568c5b3d1c1b663b2c96031 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 14:38:25 -0700 Subject: [PATCH 03/30] chore(plugins): regenerate manifest registry to include menu-plugin Ran generate-plugin-registry.mjs to pick up the new menu-plugin manifest.json. core-menu now appears in the /admin/plugins list. Co-Authored-By: Claude Sonnet 4.6 --- .../core/src/plugins/manifest-registry.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core/src/plugins/manifest-registry.ts b/packages/core/src/plugins/manifest-registry.ts index da3ba7f75..203d43101 100644 --- a/packages/core/src/plugins/manifest-registry.ts +++ b/packages/core/src/plugins/manifest-registry.ts @@ -2,7 +2,7 @@ * Plugin Registry - AUTO-GENERATED * * Generated by: packages/scripts/generate-plugin-registry.mjs - * Generated at: 2026-06-19T01:07:29.726Z + * Generated at: 2026-06-22T21:37:52.237Z * Source: All manifest.json files in src/plugins/ * * DO NOT EDIT MANUALLY - run the generator script instead. @@ -209,6 +209,27 @@ export const PLUGIN_REGISTRY: Record = { } }, + 'core-menu': { + "id": "core-menu", + "codeName": "core-menu", + "displayName": "Menu Manager", + "description": "Manages the admin sidebar navigation. Admins can reorder, rename, hide built-in items, and add custom links.", + "version": "1.0.0", + "author": "SonicJS Team", + "category": "admin", + "iconEmoji": "📋", + "is_core": true, + "permissions": [], + "dependencies": [], + "defaultSettings": {}, + "adminMenu": { + "label": "Menu", + "icon": "bars-3", + "path": "/admin/menu", + "order": 90 + } + }, + 'database-tools': { "id": "database-tools", "codeName": "database-tools", From 6404d90005f57bbcb20b6e0485bccb4dc7847daf Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 14:42:49 -0700 Subject: [PATCH 04/30] fix(menu-plugin): rename id to 'menu', restore to core, redirect plugin page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename plugin id core-menu → menu (no 'core' prefix) - Re-add to corePluginsBeforeCatchAll so /admin/menu routes always mount - Regenerate manifest-registry.ts to pick up renamed id - /admin/plugins/menu redirects to /admin/menu (the actual editor) Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/app.ts | 2 + .../core/src/plugins/core-plugins/index.ts | 1 + .../plugins/core-plugins/menu-plugin/index.ts | 4 +- .../core-plugins/menu-plugin/manifest.json | 2 +- .../core/src/plugins/manifest-registry.ts | 44 +++++++++---------- packages/core/src/routes/admin-plugins.ts | 3 ++ 6 files changed, 31 insertions(+), 25 deletions(-) diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index a4c6953de..34887245f 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -46,6 +46,7 @@ import { requireAuth, requireRole, requireRbac } from './middleware/auth' import { createAuth } from './auth/config' import { adminRbacRoutes } from './routes/admin-rbac' import { pluginMenuMiddleware } from './middleware/plugin-menu' +import { menuPlugin } from './plugins/core-plugins/menu-plugin' import { analyticsPlugin } from './plugins/core-plugins/analytics' import { eventsApiRoutes } from './plugins/core-plugins/analytics/routes/api' import { globalVariablesPlugin } from './plugins/core-plugins/global-variables-plugin' @@ -292,6 +293,7 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { multiTenantPlugin, lexicalEditorPlugin, versioningPlugin, + menuPlugin, ] const corePluginsAfterCatchAll = [emailPlugin, magicLinkPlugin, emailReconciliationPlugin] diff --git a/packages/core/src/plugins/core-plugins/index.ts b/packages/core/src/plugins/core-plugins/index.ts index 9387651c6..1da3f345d 100644 --- a/packages/core/src/plugins/core-plugins/index.ts +++ b/packages/core/src/plugins/core-plugins/index.ts @@ -67,6 +67,7 @@ export const CORE_PLUGIN_IDS = [ 'stripe', 'multi-tenant', 'versioning', + 'menu', ] as const export type CorePluginNames = (typeof CORE_PLUGIN_IDS)[number] diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts index a4a6085e2..510835672 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts @@ -9,7 +9,7 @@ import { z } from 'zod' import type { D1Database } from '@cloudflare/workers-types' export const menuPlugin = definePlugin({ - id: 'core-menu', + id: 'menu', version: '1.0.0', name: 'Menu Manager', description: 'Admin sidebar navigation manager.', @@ -36,7 +36,7 @@ export const menuPlugin = definePlugin({ displayName: 'Menu Item', description: 'Admin sidebar navigation item', schema: z.object({}), - pluginId: 'core-menu', + pluginId: 'menu', source: 'plugin', queryableFields: [ { name: 'parent', path: '$.parent', kind: 'scalar', type: 'text', column: 'q_menu_parent' }, diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json index 065b63f0c..b734f0107 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json @@ -1,5 +1,5 @@ { - "id": "core-menu", + "id": "menu", "name": "Menu Manager", "version": "1.0.0", "description": "Manages the admin sidebar navigation. Admins can reorder, rename, hide built-in items, and add custom links.", diff --git a/packages/core/src/plugins/manifest-registry.ts b/packages/core/src/plugins/manifest-registry.ts index 203d43101..1ca705aa5 100644 --- a/packages/core/src/plugins/manifest-registry.ts +++ b/packages/core/src/plugins/manifest-registry.ts @@ -2,7 +2,7 @@ * Plugin Registry - AUTO-GENERATED * * Generated by: packages/scripts/generate-plugin-registry.mjs - * Generated at: 2026-06-22T21:37:52.237Z + * Generated at: 2026-06-22T21:42:03.754Z * Source: All manifest.json files in src/plugins/ * * DO NOT EDIT MANUALLY - run the generator script instead. @@ -209,27 +209,6 @@ export const PLUGIN_REGISTRY: Record = { } }, - 'core-menu': { - "id": "core-menu", - "codeName": "core-menu", - "displayName": "Menu Manager", - "description": "Manages the admin sidebar navigation. Admins can reorder, rename, hide built-in items, and add custom links.", - "version": "1.0.0", - "author": "SonicJS Team", - "category": "admin", - "iconEmoji": "📋", - "is_core": true, - "permissions": [], - "dependencies": [], - "defaultSettings": {}, - "adminMenu": { - "label": "Menu", - "icon": "bars-3", - "path": "/admin/menu", - "order": 90 - } - }, - 'database-tools': { "id": "database-tools", "codeName": "database-tools", @@ -453,6 +432,27 @@ export const PLUGIN_REGISTRY: Record = { "adminMenu": null }, + 'menu': { + "id": "menu", + "codeName": "menu", + "displayName": "Menu Manager", + "description": "Manages the admin sidebar navigation. Admins can reorder, rename, hide built-in items, and add custom links.", + "version": "1.0.0", + "author": "SonicJS Team", + "category": "admin", + "iconEmoji": "📋", + "is_core": true, + "permissions": [], + "dependencies": [], + "defaultSettings": {}, + "adminMenu": { + "label": "Menu", + "icon": "bars-3", + "path": "/admin/menu", + "order": 90 + } + }, + 'multi-tenant': { "id": "multi-tenant", "codeName": "multi-tenant", diff --git a/packages/core/src/routes/admin-plugins.ts b/packages/core/src/routes/admin-plugins.ts index efdc2941d..923d5a427 100644 --- a/packages/core/src/routes/admin-plugins.ts +++ b/packages/core/src/routes/admin-plugins.ts @@ -131,6 +131,9 @@ adminPluginRoutes.get('/', async (c) => { } }) +// Menu plugin settings page redirects to the dedicated menu editor +adminPluginRoutes.get('/menu', (c) => c.redirect('/admin/menu')) + // Get plugin settings page adminPluginRoutes.get('/:id', async (c) => { try { From 51be3c98aa3723c4be28b0d0b7ca834b8e01e9b1 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 14:47:21 -0700 Subject: [PATCH 05/30] fix(menu-plugin): remove duplicate Menu sidebar entry adminMenu: null in manifest stops pluginMenuMiddleware from injecting a second 'Menu' link via . Added 'Menu' as a 5th system seed item (sortOrder 50) so it still appears in the data-driven sidebar managed by menuMiddleware. Co-Authored-By: Claude Sonnet 4.6 --- .../core-plugins/menu-plugin/manifest.json | 7 +------ .../menu-plugin/services/menu-defaults.ts | 15 +++++++++++++++ packages/core/src/plugins/manifest-registry.ts | 9 ++------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json index b734f0107..cb066f3b7 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json @@ -10,12 +10,7 @@ "dependencies": [], "routes": [], "permissions": {}, - "adminMenu": { - "label": "Menu", - "icon": "bars-3", - "path": "/admin/menu", - "order": 90 - }, + "adminMenu": null, "iconEmoji": "📋", "is_core": true, "defaultSettings": {} diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts index af5b61f12..2491db845 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts @@ -75,4 +75,19 @@ export const SYSTEM_MENU_ITEMS: readonly SystemMenuItem[] = [ lockedFields: ['url', 'parent'], sortOrder: 40, }, + { + id: 'menu:system:menu', + label: 'Menu', + url: '/admin/menu', + icon: 'bars-3', + target: '_self', + isExternal: false, + visible: true, + parent: null, + source: 'system', + pluginId: null, + permissions: [], + lockedFields: ['url', 'parent'], + sortOrder: 50, + }, ] as const diff --git a/packages/core/src/plugins/manifest-registry.ts b/packages/core/src/plugins/manifest-registry.ts index 1ca705aa5..13c68cf64 100644 --- a/packages/core/src/plugins/manifest-registry.ts +++ b/packages/core/src/plugins/manifest-registry.ts @@ -2,7 +2,7 @@ * Plugin Registry - AUTO-GENERATED * * Generated by: packages/scripts/generate-plugin-registry.mjs - * Generated at: 2026-06-22T21:42:03.754Z + * Generated at: 2026-06-22T21:47:02.489Z * Source: All manifest.json files in src/plugins/ * * DO NOT EDIT MANUALLY - run the generator script instead. @@ -445,12 +445,7 @@ export const PLUGIN_REGISTRY: Record = { "permissions": [], "dependencies": [], "defaultSettings": {}, - "adminMenu": { - "label": "Menu", - "icon": "bars-3", - "path": "/admin/menu", - "order": 90 - } + "adminMenu": null }, 'multi-tenant': { From d12193a569426d6056dcee909306b61bcc97bb02 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 14:56:59 -0700 Subject: [PATCH 06/30] fix(menu-plugin): add Menu to hardcoded sidebar fallback, hide orphaned plugin DB records - Add 'Menu' item to baseMenuItems in admin-layout-catalyst so the link always shows even before menu_item docs are seeded (hardcoded fallback) - Filter installed plugins whose IDs are absent from PLUGIN_REGISTRY (e.g. stale 'core-menu' DB records after rename to 'menu') so they no longer cause duplicates on the plugins list page Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/routes/admin-plugins.ts | 10 +++++++--- .../layouts/admin-layout-catalyst.template.ts | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/core/src/routes/admin-plugins.ts b/packages/core/src/routes/admin-plugins.ts index 923d5a427..0299726b8 100644 --- a/packages/core/src/routes/admin-plugins.ts +++ b/packages/core/src/routes/admin-plugins.ts @@ -62,14 +62,18 @@ adminPluginRoutes.get('/', async (c) => { // Continue with empty data } + // Filter out DB records whose IDs no longer exist in the registry (e.g. renamed plugins) + const registryIds = new Set(AVAILABLE_PLUGINS.map(p => p.id)) + const validInstalledPlugins = installedPlugins.filter(p => registryIds.has(p.id)) + // Get list of installed plugin IDs - const installedPluginIds = new Set(installedPlugins.map(p => p.id)) + const installedPluginIds = new Set(validInstalledPlugins.map(p => p.id)) // Find uninstalled plugins const uninstalledPlugins = AVAILABLE_PLUGINS.filter(p => !installedPluginIds.has(p.id)) // Map installed plugins to template format - const templatePlugins: Plugin[] = installedPlugins.map(p => ({ + const templatePlugins: Plugin[] = validInstalledPlugins.map(p => ({ id: p.id, name: p.name, displayName: p.display_name, @@ -111,7 +115,7 @@ adminPluginRoutes.get('/', async (c) => { // Update stats with uninstalled count stats.uninstalled = uninstalledPlugins.length - stats.total = installedPlugins.length + uninstalledPlugins.length + stats.total = validInstalledPlugins.length + uninstalledPlugins.length const pageData: PluginsListPageData = { plugins: allPlugins, diff --git a/packages/core/src/templates/layouts/admin-layout-catalyst.template.ts b/packages/core/src/templates/layouts/admin-layout-catalyst.template.ts index a0bc6a07c..b76f93b9b 100644 --- a/packages/core/src/templates/layouts/admin-layout-catalyst.template.ts +++ b/packages/core/src/templates/layouts/admin-layout-catalyst.template.ts @@ -598,6 +598,13 @@ function renderCatalystSidebar( `, }, + { + label: "Menu", + path: "/admin/menu", + icon: ` + + `, + }, ]; const pluginsIcon = ` From 9451ef8702da9159472d8f411fd770b03c62f0a5 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 15:06:38 -0700 Subject: [PATCH 07/30] feat(menu-plugin): revert to hardcoded sidebar when plugin is disabled menuMiddleware checks the plugins table for the 'menu' plugin status before replacing the sidebar. If status is 'inactive' (disabled via admin UI), the hardcoded sidebar fallback is used instead. Treats missing DB record as active so initial seeding still works. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/middleware/menu.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/core/src/middleware/menu.ts b/packages/core/src/middleware/menu.ts index e0eca9fe3..86739d710 100644 --- a/packages/core/src/middleware/menu.ts +++ b/packages/core/src/middleware/menu.ts @@ -113,6 +113,19 @@ const ACCORDION_SCRIPT = ` }) ` +async function isMenuPluginActive(db: any): Promise { + try { + const row = await db + .prepare(`SELECT status FROM plugins WHERE id = 'menu' LIMIT 1`) + .first() as { status: string } | null + // Not yet in DB (never installed) → treat as active so onBoot seeding works + if (!row) return true + return row.status === 'active' + } catch { + return true + } +} + export function menuMiddleware() { return async (c: Context<{ Bindings: Bindings; Variables: Variables }>, next: Next) => { const path = new URL(c.req.url).pathname @@ -124,6 +137,9 @@ export function menuMiddleware() { if (!c.res.headers.get('content-type')?.includes('text/html')) return + // Revert to hardcoded sidebar when plugin is explicitly disabled + if (!(await isMenuPluginActive(c.env.DB))) return + let items: Awaited> = [] try { items = await listMenuItems(c.env.DB) From 35f9460d88ef16726720886b4b7e25a92caeb671 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 15:14:41 -0700 Subject: [PATCH 08/30] fix(menu-plugin): query documents table for plugin status, not legacy plugins table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PluginService stores plugins in documents (type_id='plugin', slug=id), not the legacy Drizzle-schema plugins table. The previous query always returned null → fell back to true → sidebar never reverted when disabled. Now queries json_extract(data, '$.status') from documents correctly. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/middleware/menu.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/middleware/menu.ts b/packages/core/src/middleware/menu.ts index 86739d710..544eb09d3 100644 --- a/packages/core/src/middleware/menu.ts +++ b/packages/core/src/middleware/menu.ts @@ -115,10 +115,17 @@ const ACCORDION_SCRIPT = ` async function isMenuPluginActive(db: any): Promise { try { + // Plugins live in documents (type_id='plugin', slug=pluginId) — not the legacy plugins table const row = await db - .prepare(`SELECT status FROM plugins WHERE id = 'menu' LIMIT 1`) + .prepare( + `SELECT json_extract(data, '$.status') AS status + FROM documents + WHERE slug = 'menu' AND type_id = 'plugin' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL + LIMIT 1`, + ) .first() as { status: string } | null - // Not yet in DB (never installed) → treat as active so onBoot seeding works + // Not yet in DB (never installed/auto-registered) → treat as active if (!row) return true return row.status === 'active' } catch { From 9ab831d335cbbab3eb2e2f31b24650677a64fb84 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 16:13:16 -0700 Subject: [PATCH 09/30] fix(menu-plugin): remove Menu from hardcoded sidebar when plugin inactive Menu item was in baseMenuItems unconditionally. Now only appears via the data-driven sidebar (menuMiddleware) when plugin is active. Co-Authored-By: Claude Sonnet 4.6 --- .../templates/layouts/admin-layout-catalyst.template.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/core/src/templates/layouts/admin-layout-catalyst.template.ts b/packages/core/src/templates/layouts/admin-layout-catalyst.template.ts index b76f93b9b..a0bc6a07c 100644 --- a/packages/core/src/templates/layouts/admin-layout-catalyst.template.ts +++ b/packages/core/src/templates/layouts/admin-layout-catalyst.template.ts @@ -598,13 +598,6 @@ function renderCatalystSidebar( `, }, - { - label: "Menu", - path: "/admin/menu", - icon: ` - - `, - }, ]; const pluginsIcon = ` From b3b14c3f27c85e619d09024efdb7221cc0d749d4 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 19:04:22 -0700 Subject: [PATCH 10/30] feat(plugins): add settingsTabContent extension point to plugin SDK Plugins can now self-register a custom settings tab via definePlugin's settingsTabContent field: loadData(db) fetches server-side data, render() produces the HTML embedded in the Settings tab of /admin/plugins/:id. Menu plugin uses this to embed the menu editor inline, replacing the redirect to /admin/menu. Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/core-plugins/menu-plugin/index.ts | 14 ++- .../templates/admin-menu-list.template.ts | 103 ++++++++++++++++++ .../core/src/plugins/sdk/define-plugin.ts | 17 +++ .../core/src/plugins/sdk/register-plugins.ts | 5 + packages/core/src/routes/admin-plugins.ts | 17 ++- .../pages/admin-plugin-settings.template.ts | 17 ++- 6 files changed, 164 insertions(+), 9 deletions(-) diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts index 510835672..39fb9a5bb 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts @@ -1,10 +1,11 @@ import { definePlugin } from '../../sdk' import { DocumentTypeRegistry } from '../../../services/document-type-registry' import { SYSTEM_MENU_ITEMS } from './services/menu-defaults' -import { upsertSystemItem } from './services/menu-repository' +import { upsertSystemItem, listMenuItems } from './services/menu-repository' import { reconcileMenuFromPlugins } from './services/menu-reconcile' import { adminMenuRoutes } from './routes/admin-menu' import { menuMiddleware } from '../../../middleware/menu' +import { renderMenuSettingsContent } from './templates/admin-menu-list.template' import { z } from 'zod' import type { D1Database } from '@cloudflare/workers-types' @@ -73,6 +74,17 @@ export const menuPlugin = definePlugin({ // DB might not be ready (pre-migration) — skip silently } }, + + settingsTabContent: { + async loadData(db: any) { + const items = await listMenuItems(db) + return { items } + }, + render({ data }) { + const items = data?.items ?? [] + return renderMenuSettingsContent(items) + }, + }, }) export function createMenuPlugin() { diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts index 544cd8051..fb9d8fa82 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts @@ -178,3 +178,106 @@ export function renderMenuListPage(data: MenuListPageData): string { return renderAdminLayoutCatalyst(layoutData) } + +/** + * Renders just the menu items table for embedding in the plugin settings tab. + * No layout wrapper — called from the plugin's settingsTabContent.render(). + */ +export function renderMenuSettingsContent(items: MenuItem[], message?: string): string { + const messageBanner = message + ? `
${escapeHtml(message)}
` + : '' + + const rows = items.map((item, index) => { + const isFirst = index === 0 + const isLast = index === items.length - 1 + + const moveUpForm = !isFirst + ? `
+ +
` + : `` + + const moveDownForm = !isLast + ? `
+ +
` + : `` + + const deleteButton = item.source === 'user' + ? `
+ +
` + : '' + + return ` + + +
${moveUpForm}${moveDownForm}
+ + ${iconPreview(item.icon)} + + ${escapeHtml(item.label)} + ${item.parent ? `child` : ''} + + + ${escapeHtml(item.url)} + + ${sourceBadge(item.source)} + +
+ +
+ + +
+ Edit + ${deleteButton} +
+ + ` + }).join('\n') + + const emptyState = items.length === 0 + ? `No menu items yet. Add a link to get started.` + : '' + + return ` +
+
+

Manage navigation links and their order in the sidebar.

+ + + Add Link + +
+ ${messageBanner} +
+ + + + + + + + + + + + + + ${rows} + ${emptyState} + +
OrderIconLabelURLSourceVisibleActions
+
+
+ ` +} diff --git a/packages/core/src/plugins/sdk/define-plugin.ts b/packages/core/src/plugins/sdk/define-plugin.ts index aedc222c7..1abf9e129 100644 --- a/packages/core/src/plugins/sdk/define-plugin.ts +++ b/packages/core/src/plugins/sdk/define-plugin.ts @@ -207,6 +207,17 @@ export interface DefinePluginInput Promise + render: (props: { plugin: any; settings: any; data?: any }) => string + } } /** @@ -241,6 +252,11 @@ export interface DefinedPlugin { menu?: PluginMenuEntry[] /** Schema-driven settings. Renders the admin settings form for this plugin. */ configSchema?: ConfigSchema + /** Custom settings-tab renderer. loadData runs server-side; render produces the tab HTML. */ + settingsTabContent?: { + loadData?: (db: any) => Promise + render: (props: { plugin: any; settings: any; data?: any }) => string + } /** Marker so tooling/tests can detect a v3-defined plugin. */ // eslint-disable-next-line @typescript-eslint/naming-convention -- intentional internal marker readonly __sonicV3: true @@ -362,6 +378,7 @@ export function definePlugin Promise + render: (props: { plugin: any; settings: any; data?: any }) => string + } } // ── Host context ───────────────────────────────────────────────────────────── diff --git a/packages/core/src/routes/admin-plugins.ts b/packages/core/src/routes/admin-plugins.ts index 0299726b8..2dca5c9bc 100644 --- a/packages/core/src/routes/admin-plugins.ts +++ b/packages/core/src/routes/admin-plugins.ts @@ -135,9 +135,6 @@ adminPluginRoutes.get('/', async (c) => { } }) -// Menu plugin settings page redirects to the dedicated menu editor -adminPluginRoutes.get('/menu', (c) => c.redirect('/admin/menu')) - // Get plugin settings page adminPluginRoutes.get('/:id', async (c) => { try { @@ -247,6 +244,17 @@ adminPluginRoutes.get('/:id', async (c) => { user: item.userId || null })) + // Load plugin-specific settings tab data if the plugin definition declares loadData + const pluginDef = getPluginDefinition(pluginId) + let settingsTabData: any = undefined + if (pluginDef?.settingsTabContent?.loadData) { + try { + settingsTabData = await pluginDef.settingsTabContent.loadData(db) + } catch (e) { + console.error(`settingsTabContent.loadData failed for plugin "${pluginId}":`, e) + } + } + const pageData: PluginSettingsPageData = { plugin: templatePlugin, activity: templateActivity, @@ -254,7 +262,8 @@ adminPluginRoutes.get('/:id', async (c) => { name: user?.email || 'User', email: user?.email || '', role: user?.role || 'user' - } + }, + settingsTabData, } return c.html(renderPluginSettingsPage(pageData)) diff --git a/packages/core/src/templates/pages/admin-plugin-settings.template.ts b/packages/core/src/templates/pages/admin-plugin-settings.template.ts index d62617f13..92ce16c1a 100644 --- a/packages/core/src/templates/pages/admin-plugin-settings.template.ts +++ b/packages/core/src/templates/pages/admin-plugin-settings.template.ts @@ -1,6 +1,7 @@ import { renderAdminLayout, AdminLayoutData } from '../layouts/admin-layout-v2.template' import { renderAuthSettingsForm } from '../components/auth-settings-form.template' import type { AuthSettings } from '../../services/auth-validation' +import { getPluginDefinition } from '../../services/plugin-definition-registry' /** * Escape HTML attribute values to prevent XSS @@ -56,10 +57,12 @@ export interface PluginSettingsPageData { email: string role: string } + /** Data returned by the plugin's settingsTabContent.loadData(), if defined. */ + settingsTabData?: any } export function renderPluginSettingsPage(data: PluginSettingsPageData): string { - const { plugin, activity = [], user } = data + const { plugin, activity = [], user, settingsTabData } = data const pageContent = `
@@ -126,7 +129,7 @@ export function renderPluginSettingsPage(data: PluginSettingsPageData): string {
- ${renderSettingsTab(plugin)} + ${renderSettingsTab(plugin, settingsTabData)}
@@ -342,11 +345,17 @@ function renderToggleButton(plugin: any): string { : `` } -function renderSettingsTab(plugin: any): string { +function renderSettingsTab(plugin: any, settingsTabData?: any): string { const settings = plugin.settings || {} const pluginId = plugin.id || plugin.name - // Check for custom settings component first + // Check plugin definition registry first (plugins self-register via definePlugin settingsTabContent) + const pluginDef = getPluginDefinition(pluginId) + if (pluginDef?.settingsTabContent) { + return pluginDef.settingsTabContent.render({ plugin, settings, data: settingsTabData }) + } + + // Legacy: hardcoded per-plugin renderers const customRenderer = pluginSettingsComponents[pluginId] if (customRenderer) { return ` From b8bf8562b9263fcb91e1372f0cec97c7f7630bfb Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Mon, 22 Jun 2026 19:07:46 -0700 Subject: [PATCH 11/30] fix(menu-plugin): hide plugin menu items when plugin is deactivated reconcileMenuFromPlugins now queries inactive plugin IDs from the documents table and excludes them from the active set, so deactivateStalePluginRows hides their menu items. reconcile also runs immediately on activate/deactivate so the change takes effect without waiting for next boot. Co-Authored-By: Claude Sonnet 4.6 --- .../menu-plugin/services/menu-reconcile.ts | 25 ++++++++++++++++++- packages/core/src/routes/admin-plugins.ts | 3 +++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts index 6a7109dc6..03e3cbb9f 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts @@ -116,6 +116,22 @@ async function deactivateStalePluginRows( ) } +async function fetchInactivePluginIds(db: D1Database): Promise> { + try { + const rows = await db + .prepare( + `SELECT slug FROM documents + WHERE type_id = 'plugin' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL + AND json_extract(data, '$.status') = 'inactive'`, + ) + .all<{ slug: string }>() + return new Set((rows.results ?? []).map((r) => r.slug)) + } catch { + return new Set() + } +} + export async function reconcileMenuFromPlugins(db: D1Database): Promise { try { // Source A: code-declared plugins via definePlugin({ menu: [...] }) @@ -162,13 +178,20 @@ export async function reconcileMenuFromPlugins(db: D1Database): Promise { }) }) + // Exclude plugins explicitly disabled in the DB — their menu rows will be + // hidden by deactivateStalePluginRows below (same path as removed plugins). + const inactiveIds = await fetchInactivePluginIds(db) + for (const id of inactiveIds) { + byPluginId.delete(id) + } + // Upsert each active plugin entry for (const [pluginId, entry] of byPluginId) { const slug = `menu:plugin:${pluginId}` await upsertPluginRow(db, slug, entry) } - // Deactivate rows whose plugin is no longer in the active set + // Deactivate rows whose plugin is no longer active (removed or disabled) await deactivateStalePluginRows(db, new Set(byPluginId.keys())) } catch { // DB may not be ready at bootstrap; swallow silently diff --git a/packages/core/src/routes/admin-plugins.ts b/packages/core/src/routes/admin-plugins.ts index 2dca5c9bc..7fee3e405 100644 --- a/packages/core/src/routes/admin-plugins.ts +++ b/packages/core/src/routes/admin-plugins.ts @@ -12,6 +12,7 @@ import { parseFormDataToSettings, applySchemaDefaults, } from '../plugins/sdk/config-schema' +import { reconcileMenuFromPlugins } from '../plugins/core-plugins/menu-plugin/services/menu-reconcile' import type { Bindings, Variables } from '../app' const adminPluginRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>() @@ -366,6 +367,7 @@ adminPluginRoutes.post('/:id/activate', async (c) => { const pluginService = new PluginService(db) await pluginService.activatePlugin(pluginId) + await reconcileMenuFromPlugins(db) return c.json({ success: true }) } catch (error) { @@ -389,6 +391,7 @@ adminPluginRoutes.post('/:id/deactivate', async (c) => { const pluginService = new PluginService(db) await pluginService.deactivatePlugin(pluginId) + await reconcileMenuFromPlugins(db) return c.json({ success: true }) } catch (error) { From cd87dfc70d3b18fd9a10d48b36a17292b5ed4e50 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Tue, 23 Jun 2026 10:17:38 -0700 Subject: [PATCH 12/30] fix(menu-plugin): fix CSRF and visible value on visibility toggle forms - form.submit() doesn't fire submit event so CSRF auto-injection in catalyst layout never ran; switch to requestSubmit() which fires it - renderMenuSettingsContent runs in v2 layout (no CSRF script) so add inline CSRF injection script scoped to that embedded content - fix visible checkbox value from '1' to 'true' to match route check Co-Authored-By: Claude Sonnet 4.6 --- .../templates/admin-menu-list.template.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts index fb9d8fa82..73f421c8e 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts @@ -97,9 +97,9 @@ export function renderMenuListPage(data: MenuListPageData): string {
@@ -231,7 +231,7 @@ export function renderMenuSettingsContent(items: MenuItem[], message?: string):
@@ -250,6 +250,26 @@ export function renderMenuSettingsContent(items: MenuItem[], message?: string): : '' return ` +

Manage navigation links and their order in the sidebar.

From acf3a60efae46fe100f25c60f8d1cd57e2c3f3d3 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Tue, 23 Jun 2026 10:27:37 -0700 Subject: [PATCH 13/30] feat(menu-plugin): show plugin enabled/disabled status in menu manager list Add fetchPluginStatuses() to query plugin status from documents table. Plugin-sourced menu items now show an 'enabled' or 'disabled' badge alongside the source badge in both the standalone /admin/menu page and the embedded settings tab. Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/core-plugins/menu-plugin/index.ts | 9 +++-- .../menu-plugin/routes/admin-menu.ts | 4 +++ .../menu-plugin/services/menu-repository.ts | 33 +++++++++++++++++++ .../templates/admin-menu-list.template.ts | 14 ++++++-- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts index 39fb9a5bb..a5d12da82 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts @@ -1,7 +1,7 @@ import { definePlugin } from '../../sdk' import { DocumentTypeRegistry } from '../../../services/document-type-registry' import { SYSTEM_MENU_ITEMS } from './services/menu-defaults' -import { upsertSystemItem, listMenuItems } from './services/menu-repository' +import { upsertSystemItem, listMenuItems, fetchPluginStatuses } from './services/menu-repository' import { reconcileMenuFromPlugins } from './services/menu-reconcile' import { adminMenuRoutes } from './routes/admin-menu' import { menuMiddleware } from '../../../middleware/menu' @@ -78,11 +78,14 @@ export const menuPlugin = definePlugin({ settingsTabContent: { async loadData(db: any) { const items = await listMenuItems(db) - return { items } + const pluginIds = [...new Set(items.filter(i => i.pluginId).map(i => i.pluginId as string))] + const pluginStatuses = await fetchPluginStatuses(db, pluginIds) + return { items, pluginStatuses } }, render({ data }) { const items = data?.items ?? [] - return renderMenuSettingsContent(items) + const pluginStatuses = data?.pluginStatuses ?? {} + return renderMenuSettingsContent(items, pluginStatuses) }, }, }) diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts b/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts index d5e0b6a0b..00bc45f80 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts @@ -9,6 +9,7 @@ import { deleteItem, toggleVisibility, reorderItems, + fetchPluginStatuses, } from '../services/menu-repository' import { nanoid } from 'nanoid' import { D1Database } from '@cloudflare/workers-types' @@ -26,9 +27,12 @@ adminMenuRoutes.get('/', async (c) => { const user = c.get('user') const items = await listMenuItems(db) const tree = buildSidebarTree(items) + const pluginIds = [...new Set(items.filter(i => i.pluginId).map(i => i.pluginId as string))] + const pluginStatuses = await fetchPluginStatuses(db, pluginIds) return c.html(renderMenuListPage({ items, tree, + pluginStatuses, user, currentPath: '/admin/menu', version: c.get('appVersion'), diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts index 2df105ca0..8ffd58a5a 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts @@ -80,6 +80,39 @@ export async function listMenuItems( } } +/** + * Returns a map of pluginId → 'active'|'inactive' for the given plugin IDs. + * Plugins not yet in the DB (never installed) are treated as 'active'. + */ +export async function fetchPluginStatuses( + db: D1Database, + pluginIds: string[], +): Promise> { + if (pluginIds.length === 0) return {} + try { + const placeholders = pluginIds.map(() => '?').join(',') + const rows = await db + .prepare( + `SELECT slug, json_extract(data, '$.status') AS status + FROM documents + WHERE type_id = 'plugin' AND tenant_id = 'default' + AND is_current_draft = 1 AND deleted_at IS NULL + AND slug IN (${placeholders})`, + ) + .bind(...pluginIds) + .all<{ slug: string; status: string | null }>() + + const result: Record = {} + for (const id of pluginIds) result[id] = 'active' // default: active + for (const row of rows.results ?? []) { + result[row.slug] = row.status === 'inactive' ? 'inactive' : 'active' + } + return result + } catch { + return {} + } +} + export function buildSidebarTree(items: MenuItem[]): SidebarItem[] { const visible = items.filter((i) => i.visible) visible.sort((a, b) => a.sortOrder - b.sortOrder) diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts index 73f421c8e..01a861a1c 100644 --- a/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts +++ b/packages/core/src/plugins/core-plugins/menu-plugin/templates/admin-menu-list.template.ts @@ -5,6 +5,7 @@ import type { MenuItem, SidebarItem } from '../services/menu-repository' interface MenuListPageData { items: MenuItem[] tree: SidebarItem[] + pluginStatuses?: Record user?: { name?: string; email?: string; role?: string } currentPath?: string version?: string @@ -12,6 +13,13 @@ interface MenuListPageData { message?: string } +function pluginStatusBadge(status: 'active' | 'inactive'): string { + if (status === 'inactive') { + return `disabled` + } + return `enabled` +} + function sourceBadge(source: string): string { switch (source) { case 'system': @@ -38,6 +46,7 @@ export function renderMenuListPage(data: MenuListPageData): string {
` : '' + const pluginStatuses = data.pluginStatuses ?? {} const rows = data.items.map((item, index) => { const isFirst = index === 0 const isLast = index === data.items.length - 1 @@ -90,6 +99,7 @@ export function renderMenuListPage(data: MenuListPageData): string { ${sourceBadge(item.source)} + ${item.source === 'plugin' && item.pluginId ? pluginStatusBadge(pluginStatuses[item.pluginId] ?? 'active') : ''}
@@ -183,7 +193,7 @@ export function renderMenuListPage(data: MenuListPageData): string { * Renders just the menu items table for embedding in the plugin settings tab. * No layout wrapper — called from the plugin's settingsTabContent.render(). */ -export function renderMenuSettingsContent(items: MenuItem[], message?: string): string { +export function renderMenuSettingsContent(items: MenuItem[], pluginStatuses: Record = {}, message?: string): string { const messageBanner = message ? `
${escapeHtml(message)}
` : '' @@ -227,7 +237,7 @@ export function renderMenuSettingsContent(items: MenuItem[], message?: string): ${escapeHtml(item.url)} - ${sourceBadge(item.source)} + ${sourceBadge(item.source)}${item.source === 'plugin' && item.pluginId ? pluginStatusBadge(pluginStatuses[item.pluginId] ?? 'active') : ''}