From a849ddb5e98be29da569cbc420011aa87eba1a10 Mon Sep 17 00:00:00 2001 From: brad Date: Wed, 8 Apr 2026 13:28:41 -0500 Subject: [PATCH] feat: add rich text editor integration for global variables & shortcodes plugins (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Part 2 (Rich Text Inline Tokens) from #719: - Enhanced global-variables-plugin with full CRUD admin page, Quill blots (blue chips), TinyMCE PluginManager integration, and editor toggle - New shortcodes-plugin with [[shortcode]] syntax, handler registry, CRUD admin, live preview, Quill blots (purple chips), and TinyMCE support - Shared editor utilities: Quill constructor Proxy, searchable picker dropdown, TinyMCE PluginManager approach, admin HTML template - Content resolution: variables (priority 50) → shortcodes (priority 60) - 5 built-in shortcode handlers: current_date, phone_link, cta_button, plan_count, provider_rating Closes #719 Extends #743 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core-plugins/_shared/admin-template.ts | 68 ++ .../src/plugins/core-plugins/_shared/index.ts | 3 + .../core-plugins/_shared/quill-shared.ts | 400 +++++++ .../core-plugins/_shared/tinymce-shared.ts | 204 ++++ .../global-variables-plugin/index.ts | 1007 +++++++++++------ .../global-variables-plugin/manifest.json | 35 +- .../core/src/plugins/core-plugins/index.ts | 8 + .../core-plugins/shortcodes-plugin/index.ts | 742 ++++++++++++ .../shortcodes-plugin/manifest.json | 45 + .../shortcodes-plugin/shortcode-resolver.ts | 181 +++ 10 files changed, 2351 insertions(+), 342 deletions(-) create mode 100644 packages/core/src/plugins/core-plugins/_shared/admin-template.ts create mode 100644 packages/core/src/plugins/core-plugins/_shared/index.ts create mode 100644 packages/core/src/plugins/core-plugins/_shared/quill-shared.ts create mode 100644 packages/core/src/plugins/core-plugins/_shared/tinymce-shared.ts create mode 100644 packages/core/src/plugins/core-plugins/shortcodes-plugin/index.ts create mode 100644 packages/core/src/plugins/core-plugins/shortcodes-plugin/manifest.json create mode 100644 packages/core/src/plugins/core-plugins/shortcodes-plugin/shortcode-resolver.ts diff --git a/packages/core/src/plugins/core-plugins/_shared/admin-template.ts b/packages/core/src/plugins/core-plugins/_shared/admin-template.ts new file mode 100644 index 000000000..1f35222bf --- /dev/null +++ b/packages/core/src/plugins/core-plugins/_shared/admin-template.ts @@ -0,0 +1,68 @@ +/** + * Shared Admin Page Template + * + * Provides the HTML wrapper for plugin admin pages with: + * - Tailwind CSS (CDN) with dark mode + * - HTMX for AJAX operations + * - CSRF token auto-injection (matches core admin layout pattern) + * - Inter font + */ + +export function wrapAdminPage(opts: { + title: string + body: string +}): string { + return ` + + + + + ${opts.title} - SonicJS + + + + + + + + + + + ${opts.body} + +` +} diff --git a/packages/core/src/plugins/core-plugins/_shared/index.ts b/packages/core/src/plugins/core-plugins/_shared/index.ts new file mode 100644 index 000000000..66e4c2774 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/_shared/index.ts @@ -0,0 +1,3 @@ +export { wrapAdminPage } from './admin-template' +export { getSharedQuillStyles, getSharedQuillScript, getQuillEnhancerPollerScript } from './quill-shared' +export { getSharedTinyMceStyles, getTinyMcePluginScript } from './tinymce-shared' diff --git a/packages/core/src/plugins/core-plugins/_shared/quill-shared.ts b/packages/core/src/plugins/core-plugins/_shared/quill-shared.ts new file mode 100644 index 000000000..49358580c --- /dev/null +++ b/packages/core/src/plugins/core-plugins/_shared/quill-shared.ts @@ -0,0 +1,400 @@ +/** + * Shared Quill Enhancement Utilities + * + * Provides the common CSS, picker dropdown, Quill constructor proxy, and + * helper functions used by both the Global Variables and Shortcodes plugins + * for their Quill editor integrations. + * + * Each plugin calls getSharedQuillSetup() to get the shared `; +} + +/** + * Returns the shared JavaScript that sets up: + * - Quill constructor Proxy (injects custom formats into the whitelist) + * - Picker dropdown component (showPicker, closePicker) + * - insertBlot helper + * - enhanceQuillEditors polling framework + * + * Idempotent: checks window.__sonicQuillShared before running. + * Each plugin adds its own blot registration + toolbar button AFTER this. + */ +export function getSharedQuillScript(): string { + return ` + `; +} + +/** + * Returns the polling script that enhances Quill editors with custom buttons. + * Each plugin registers its own enhancer function on window.__sonicQuillEnhancers[], + * and this shared poller runs them all. + * + * Idempotent: only one poller runs. + */ +export function getQuillEnhancerPollerScript(): string { + return ` + `; +} diff --git a/packages/core/src/plugins/core-plugins/_shared/tinymce-shared.ts b/packages/core/src/plugins/core-plugins/_shared/tinymce-shared.ts new file mode 100644 index 000000000..bd6d9638b --- /dev/null +++ b/packages/core/src/plugins/core-plugins/_shared/tinymce-shared.ts @@ -0,0 +1,204 @@ +/** + * TinyMCE Integration — PluginManager Approach + * + * Each plugin registers via `tinymce.PluginManager.add()` which is the official + * TinyMCE way to add buttons. The plugin is registered BEFORE `tinymce.init()` + * is called, and TinyMCE invokes it during initialization. + * + * The server-side HTML replacement in index.ts already: + * 1. Adds button names (sonicInsertVar, sonicInsertSC) to the toolbar config string + * 2. Injects the script tags BEFORE the initializeTinyMCE call + * + * So by the time tinymce.init() runs, PluginManager already knows our plugins, + * the toolbar string already references our button names, and the plugins array + * includes our plugin names. All three are handled by server-side HTML replacement. + * + * Each plugin also registers chip CSS + token-to-chip conversion via SetContent/GetContent. + */ + +/** Picker dropdown CSS (shared, safe to duplicate) */ +export function getSharedTinyMceStyles(): string { + return ``; +} + +/** + * Returns a SELF-CONTAINED script that: + * - Sets up the picker dropdown (idempotent) + * - Registers a TinyMCE plugin via PluginManager.add() (the official way) + * - The plugin registers the toolbar button + chip CSS + token↔chip conversion + * + * Server-side (index.ts) handles adding button names to the toolbar string + * and plugin names to the plugins array. This script just needs to register + * the plugin in PluginManager before tinymce.init() is called. + */ +export function getTinyMcePluginScript(opts: { + buttonName: string + buttonText: string + buttonTooltip: string + pickerIcon: string + pickerLabel: string + pickerApiUrl: string + renderItemJs: string + getSearchTextJs: string + onSelectJs: string +}): string { + const chipCSS = '.sonic-var-chip{display:inline;background:rgba(59,130,246,.15);border:1px solid rgba(59,130,246,.3);border-radius:4px;padding:1px 6px;font-family:ui-monospace,monospace;font-size:.85em;color:#60a5fa;cursor:default}.sonic-var-chip::before{content:\"{\";opacity:.5}.sonic-var-chip::after{content:\"}\";opacity:.5}.sonic-sc-chip{display:inline;background:rgba(168,85,247,.15);border:1px solid rgba(168,85,247,.3);border-radius:4px;padding:1px 6px;font-family:ui-monospace,monospace;font-size:.85em;color:#c084fc;cursor:default}.sonic-sc-chip::before{content:\"[[\";opacity:.5}.sonic-sc-chip::after{content:\"]]\";opacity:.5}'; + + // Each button gets its own TinyMCE plugin name (e.g., "sonicInsertVar" -> "sonicInsertVar") + // The plugin name matches the button name for simplicity. + const pluginName = opts.buttonName; + + return ` + `; +} diff --git a/packages/core/src/plugins/core-plugins/global-variables-plugin/index.ts b/packages/core/src/plugins/core-plugins/global-variables-plugin/index.ts index 63c84c41e..8402f9c00 100644 --- a/packages/core/src/plugins/core-plugins/global-variables-plugin/index.ts +++ b/packages/core/src/plugins/core-plugins/global-variables-plugin/index.ts @@ -1,482 +1,705 @@ /** - * Global Variables Plugin + * Global Variables Plugin — Enhanced * - * Provides dynamic content variables (inline tokens) for rich text fields. - * Variables are stored as key-value pairs and can be referenced in content - * using {variable_key} syntax. They are resolved server-side on content read. + * Extends PR #743 (lane711/sonicjs) with: + * - Full CRUD admin page (add, inline edit, delete, toggle active/inactive) + * - Proper PluginBuilder lifecycle (install, activate, deactivate, uninstall) + * - Content read hook for {variable_key} server-side resolution * - * Part 1: Global Variables Collection (CRUD API + admin UI) - * Part 3: Server-Side Resolution (token replacement in content) - * Part 2: Rich Text Inline Tokens (Quill UI) - planned for future PR + * Rich text editor integration: Quill blots (blue chips) + TinyMCE buttons via PluginManager + * + * @see https://github.com/lane711/sonicjs/issues/719 + * @see https://github.com/lane711/sonicjs/pull/743 */ import { Hono } from 'hono' -import { z } from 'zod' -import type { Plugin } from '@sonicjs-cms/core' -import { PluginBuilder } from '../../sdk/plugin-builder' -import { resolveVariablesInObject } from './variable-resolver' - -// ============================================================================ -// Schema & Migration -// ============================================================================ - -export const globalVariableSchema = z.object({ - id: z.number().optional(), - key: z.string() - .min(1, 'Variable key is required') - .max(100, 'Key must be under 100 characters') - .regex(/^[a-z0-9_]+$/, 'Key must contain only lowercase letters, numbers, and underscores'), - value: z.string().max(10000, 'Value must be under 10,000 characters'), - description: z.string().max(500, 'Description must be under 500 characters').optional(), - category: z.string().max(50, 'Category must be under 50 characters').optional(), - isActive: z.boolean().default(true), - createdAt: z.number().optional(), - updatedAt: z.number().optional(), -}) - -export type GlobalVariable = z.infer - -const globalVariablesMigration = ` +import { PluginBuilder } from '../../../sdk/plugin-builder' +import { wrapAdminPage } from '../_shared/admin-template' +import { + resolveVariablesInObject, + invalidateVariablesCache, + getVariablesCached, + setVariablesCache, +} from './variable-resolver' +import { + getSharedQuillStyles, + getSharedQuillScript, + getQuillEnhancerPollerScript, +} from '../_shared/quill-shared' +import { + getSharedTinyMceStyles, + getTinyMcePluginScript, +} from '../_shared/tinymce-shared' + +// ─── Migration SQL ─────────────────────────────────────────────────────────── + +const MIGRATION_SQL = ` CREATE TABLE IF NOT EXISTS global_variables ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL DEFAULT '', description TEXT, - category TEXT, + category TEXT DEFAULT 'general', is_active INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_global_variables_key ON global_variables(key); CREATE INDEX IF NOT EXISTS idx_global_variables_category ON global_variables(category); CREATE INDEX IF NOT EXISTS idx_global_variables_active ON global_variables(is_active); - -CREATE TRIGGER IF NOT EXISTS global_variables_updated_at - AFTER UPDATE ON global_variables -BEGIN - UPDATE global_variables SET updated_at = strftime('%s', 'now') WHERE id = NEW.id; -END; ` -// ============================================================================ -// In-memory variable cache -// ============================================================================ +// ─── Helpers ───────────────────────────────────────────────────────────────── -let variableCache: Map | null = null -let cacheTimestamp = 0 -const CACHE_TTL_MS = 300_000 // 5 minutes - -async function getVariablesMap(db: any): Promise> { - const now = Date.now() - if (variableCache && (now - cacheTimestamp) < CACHE_TTL_MS) { - return variableCache +function formatVariable(row: any) { + if (!row) return null + return { + id: row.id, + key: row.key, + value: row.value, + description: row.description, + category: row.category, + isActive: row.is_active === 1 || row.is_active === true, + createdAt: row.created_at, + updatedAt: row.updated_at, } +} +function esc(s: string): string { + return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +async function getVariablesMap(db: any): Promise> { + let variables = getVariablesCached() + if (variables) return variables try { const { results } = await db.prepare( 'SELECT key, value FROM global_variables WHERE is_active = 1' ).all() - - const map = new Map() + variables = new Map() for (const row of results || []) { - map.set((row as any).key, (row as any).value) + variables.set((row as any).key, (row as any).value) } - - variableCache = map - cacheTimestamp = now - return map + setVariablesCache(variables) + return variables } catch { - // Table may not exist yet; return empty map return new Map() } } -function invalidateCache(): void { - variableCache = null - cacheTimestamp = 0 -} - -// ============================================================================ -// API Routes -// ============================================================================ +// ─── API Routes ────────────────────────────────────────────────────────────── const apiRoutes = new Hono() -// GET /api/global-variables — List all variables -apiRoutes.get('/', async (c: any) => { +// Gate: all routes return 404 if this plugin is inactive +apiRoutes.use('*', async (c: any, next: any) => { try { const db = c.env?.DB - if (!db) { - return c.json({ error: 'Database not available' }, 500) + if (db) { + const row = await db.prepare("SELECT status FROM plugins WHERE id = 'global-variables' AND status = 'active'").first() + if (!row) return c.json({ error: 'Plugin not active' }, 404) } + } catch { /* allow if table doesn't exist yet */ } + await next() +}) + +apiRoutes.get('/', async (c: any) => { + try { + const db = c.env.DB + const category = c.req.query('category') + const active = c.req.query('active') - const { category, active } = c.req.query() let query = 'SELECT * FROM global_variables WHERE 1=1' const params: any[] = [] - - if (category) { - query += ' AND category = ?' - params.push(category) - } - - if (active !== undefined) { - query += ' AND is_active = ?' - params.push(active === 'true' ? 1 : 0) - } - + if (category) { query += ' AND category = ?'; params.push(category) } + if (active !== undefined) { query += ' AND is_active = ?'; params.push(active === 'true' ? 1 : 0) } query += ' ORDER BY category ASC, key ASC' const { results } = await db.prepare(query).bind(...params).all() - - return c.json({ - success: true, - data: (results || []).map(formatVariable), - }) - } catch (error) { + return c.json({ success: true, data: (results || []).map(formatVariable) }) + } catch { return c.json({ success: false, error: 'Failed to fetch global variables' }, 500) } }) -// GET /api/global-variables/resolve — Get a flat key→value map (for frontends) apiRoutes.get('/resolve', async (c: any) => { try { - const db = c.env?.DB - if (!db) { - return c.json({ error: 'Database not available' }, 500) - } - - const map = await getVariablesMap(db) - return c.json({ - success: true, - data: Object.fromEntries(map), - }) - } catch (error) { + const map = await getVariablesMap(c.env.DB) + return c.json({ success: true, data: Object.fromEntries(map) }) + } catch { return c.json({ success: false, error: 'Failed to resolve variables' }, 500) } }) -// GET /api/global-variables/:id — Get single variable apiRoutes.get('/:id', async (c: any) => { try { - const db = c.env?.DB - if (!db) { - return c.json({ error: 'Database not available' }, 500) - } - - const id = c.req.param('id') - const result = await db.prepare('SELECT * FROM global_variables WHERE id = ?').bind(id).first() - - if (!result) { - return c.json({ error: 'Variable not found' }, 404) - } - + const result = await c.env.DB.prepare('SELECT * FROM global_variables WHERE id = ?').bind(c.req.param('id')).first() + if (!result) return c.json({ error: 'Variable not found' }, 404) return c.json({ success: true, data: formatVariable(result) }) - } catch (error) { + } catch { return c.json({ success: false, error: 'Failed to fetch variable' }, 500) } }) -// POST /api/global-variables — Create variable apiRoutes.post('/', async (c: any) => { try { - const db = c.env?.DB - if (!db) { - return c.json({ error: 'Database not available' }, 500) - } - - const body = await c.req.json() - const parsed = globalVariableSchema.safeParse(body) - if (!parsed.success) { - return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400) + const db = c.env.DB + const { key, value, description, category, isActive } = await c.req.json() + if (!key || !/^[a-z0-9_]+$/.test(key)) { + return c.json({ error: 'Key must be lowercase alphanumeric with underscores' }, 400) } - - const { key, value, description, category, isActive } = parsed.data - - // Check for duplicate key const existing = await db.prepare('SELECT id FROM global_variables WHERE key = ?').bind(key).first() - if (existing) { - return c.json({ error: `Variable with key "${key}" already exists` }, 409) - } + if (existing) return c.json({ error: `Variable with key "${key}" already exists` }, 409) await db.prepare( 'INSERT INTO global_variables (key, value, description, category, is_active) VALUES (?, ?, ?, ?, ?)' - ).bind(key, value, description || null, category || null, isActive ? 1 : 0).run() - - invalidateCache() + ).bind(key, value || '', description || '', category || 'general', isActive !== false ? 1 : 0).run() + invalidateVariablesCache() const created = await db.prepare('SELECT * FROM global_variables WHERE key = ?').bind(key).first() return c.json({ success: true, data: formatVariable(created) }, 201) - } catch (error) { + } catch { return c.json({ success: false, error: 'Failed to create variable' }, 500) } }) -// PUT /api/global-variables/:id — Update variable apiRoutes.put('/:id', async (c: any) => { try { - const db = c.env?.DB - if (!db) { - return c.json({ error: 'Database not available' }, 500) - } - + const db = c.env.DB const id = c.req.param('id') - const existing = await db.prepare('SELECT * FROM global_variables WHERE id = ?').bind(id).first() - if (!existing) { - return c.json({ error: 'Variable not found' }, 404) - } + const existing = await db.prepare('SELECT * FROM global_variables WHERE id = ?').bind(id).first() as any + if (!existing) return c.json({ error: 'Variable not found' }, 404) const body = await c.req.json() const updates: string[] = [] const params: any[] = [] + if (body.value !== undefined) { updates.push('value = ?'); params.push(body.value) } + if (body.description !== undefined) { updates.push('description = ?'); params.push(body.description) } + if (body.category !== undefined) { updates.push('category = ?'); params.push(body.category) } + if (body.isActive !== undefined) { updates.push('is_active = ?'); params.push(body.isActive ? 1 : 0) } if (body.key !== undefined) { - const keyValidation = z.string().min(1).max(100).regex(/^[a-z0-9_]+$/).safeParse(body.key) - if (!keyValidation.success) { - return c.json({ error: 'Invalid key format' }, 400) - } - // Check uniqueness if key changed - if (body.key !== (existing as any).key) { + if (!/^[a-z0-9_]+$/.test(body.key)) return c.json({ error: 'Invalid key format' }, 400) + if (body.key !== existing.key) { const dup = await db.prepare('SELECT id FROM global_variables WHERE key = ? AND id != ?').bind(body.key, id).first() - if (dup) { - return c.json({ error: `Variable with key "${body.key}" already exists` }, 409) - } + if (dup) return c.json({ error: `Key "${body.key}" already exists` }, 409) } - updates.push('key = ?') - params.push(body.key) - } - - if (body.value !== undefined) { - updates.push('value = ?') - params.push(body.value) - } - - if (body.description !== undefined) { - updates.push('description = ?') - params.push(body.description) - } - - if (body.category !== undefined) { - updates.push('category = ?') - params.push(body.category) - } - - if (body.isActive !== undefined) { - updates.push('is_active = ?') - params.push(body.isActive ? 1 : 0) - } - - if (updates.length === 0) { - return c.json({ error: 'No fields to update' }, 400) + updates.push('key = ?'); params.push(body.key) } + if (updates.length === 0) return c.json({ error: 'No fields to update' }, 400) + updates.push('updated_at = strftime(\'%s\', \'now\')') params.push(id) await db.prepare(`UPDATE global_variables SET ${updates.join(', ')} WHERE id = ?`).bind(...params).run() - - invalidateCache() + invalidateVariablesCache() const updated = await db.prepare('SELECT * FROM global_variables WHERE id = ?').bind(id).first() return c.json({ success: true, data: formatVariable(updated) }) - } catch (error) { + } catch { return c.json({ success: false, error: 'Failed to update variable' }, 500) } }) -// DELETE /api/global-variables/:id — Delete variable apiRoutes.delete('/:id', async (c: any) => { try { - const db = c.env?.DB - if (!db) { - return c.json({ error: 'Database not available' }, 500) - } - + const db = c.env.DB const id = c.req.param('id') const existing = await db.prepare('SELECT id FROM global_variables WHERE id = ?').bind(id).first() - if (!existing) { - return c.json({ error: 'Variable not found' }, 404) - } - + if (!existing) return c.json({ error: 'Variable not found' }, 404) await db.prepare('DELETE FROM global_variables WHERE id = ?').bind(id).run() - invalidateCache() - + invalidateVariablesCache() return c.json({ success: true }) - } catch (error) { + } catch { return c.json({ success: false, error: 'Failed to delete variable' }, 500) } }) -// ============================================================================ -// Admin Routes -// ============================================================================ +// ─── Admin Page ────────────────────────────────────────────────────────────── const adminRoutes = new Hono() +adminRoutes.use('*', async (c: any, next: any) => { + try { + const db = c.env?.DB + if (db) { + const row = await db.prepare("SELECT status FROM plugins WHERE id = 'global-variables' AND status = 'active'").first() + if (!row) return c.html('

Plugin not active

Enable the Global Variables plugin from Plugins.

', 404) + } + } catch { /* allow */ } + await next() +}) + adminRoutes.get('/', async (c: any) => { - const db = c.env?.DB + const db = c.env.DB let variables: any[] = [] - try { - if (db) { - const { results } = await db.prepare( - 'SELECT * FROM global_variables ORDER BY category ASC, key ASC' - ).all() - variables = (results || []).map(formatVariable) + const { results } = await db.prepare('SELECT * FROM global_variables ORDER BY category ASC, key ASC').all() + variables = (results || []).map(formatVariable) + } catch { /* table may not exist yet */ } + + // Fetch editor integration status + let editorActive = false + let activeEditorName = '' + let enableEditorIntegration = true + try { + const qeRow = await db.prepare("SELECT status FROM plugins WHERE (id = 'quill-editor' OR name = 'quill-editor') AND status = 'active'").first() + const tmRow = await db.prepare("SELECT status FROM plugins WHERE (id = 'tinymce-plugin' OR name = 'tinymce-plugin') AND status = 'active'").first() + if (qeRow) { editorActive = true; activeEditorName = 'Quill Editor' } + else if (tmRow) { editorActive = true; activeEditorName = 'TinyMCE' } + const gvRow = await db.prepare("SELECT settings FROM plugins WHERE id = 'global-variables'").first() as any + if (gvRow?.settings) { + const settings = typeof gvRow.settings === 'string' ? JSON.parse(gvRow.settings) : gvRow.settings + enableEditorIntegration = settings.enableEditorIntegration !== false } - } catch { - // Table may not exist yet + } catch { /* ignore */ } + + return c.html(renderAdminPage(variables, { editorActive, activeEditorName, enableEditorIntegration })) +}) + +// HTMX: inline update value +adminRoutes.put('/:id', async (c: any) => { + const db = c.env.DB + const id = c.req.param('id') + let body: any + try { body = await c.req.json() } catch { body = await c.req.parseBody() } + const value = body.value + if (value !== undefined) { + await db.prepare('UPDATE global_variables SET value = ?, updated_at = strftime(\'%s\', \'now\') WHERE id = ?').bind(value, id).run() + invalidateVariablesCache() + } + return c.html('Saved') +}) + +// HTMX: toggle active +adminRoutes.post('/:id/toggle', async (c: any) => { + const db = c.env.DB + const id = c.req.param('id') + await db.prepare('UPDATE global_variables SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END, updated_at = strftime(\'%s\', \'now\') WHERE id = ?').bind(id).run() + invalidateVariablesCache() + c.header('HX-Redirect', '/admin/global-variables') + return c.body(null, 204) +}) + +// HTMX: create variable +adminRoutes.post('/', async (c: any) => { + const db = c.env.DB + const form = await c.req.parseBody() + const key = (form.key as string || '').trim() + const value = (form.value as string || '').trim() + const category = (form.category as string || 'general').trim() + const description = (form.description as string || '').trim() + + if (!key || !/^[a-z0-9_]+$/.test(key)) { + return c.html('
Key must be lowercase alphanumeric with underscores only
') + } + + const existing = await db.prepare('SELECT id FROM global_variables WHERE key = ?').bind(key).first() + if (existing) { + return c.html(`
Variable with key "${esc(key)}" already exists
`) } - const categories = [...new Set(variables.map((v: any) => v.category).filter(Boolean))] + await db.prepare( + 'INSERT INTO global_variables (key, value, description, category) VALUES (?, ?, ?, ?)' + ).bind(key, value, description, category).run() + invalidateVariablesCache() - return c.html(renderAdminPage(variables, categories)) + c.header('HX-Redirect', '/admin/global-variables') + return c.body(null, 204) }) -// ============================================================================ -// Helpers -// ============================================================================ +// Toggle editor integration setting +adminRoutes.post('/settings/editor-integration', async (c: any) => { + const db = c.env.DB + try { + const row = await db.prepare("SELECT settings FROM plugins WHERE id = 'global-variables'").first() as any + const settings = row?.settings ? (typeof row.settings === 'string' ? JSON.parse(row.settings) : row.settings) : {} + settings.enableEditorIntegration = !settings.enableEditorIntegration + await db.prepare("UPDATE plugins SET settings = ? WHERE id = 'global-variables'").bind(JSON.stringify(settings)).run() + return c.json({ success: true, enableEditorIntegration: settings.enableEditorIntegration }) + } catch { + return c.json({ success: false, error: 'Failed to update setting' }, 500) + } +}) -function formatVariable(row: any): any { - if (!row) return null - return { - id: row.id, - key: row.key, - value: row.value, - description: row.description, - category: row.category, - isActive: row.is_active === 1 || row.is_active === true, - createdAt: row.created_at, - updatedAt: row.updated_at, +// HTMX: delete variable +adminRoutes.delete('/:id', async (c: any) => { + const db = c.env.DB + await db.prepare('DELETE FROM global_variables WHERE id = ?').bind(c.req.param('id')).run() + invalidateVariablesCache() + return c.html('') +}) + +// ─── Admin Page Template ───────────────────────────────────────────────────── + +function renderAdminPage(variables: any[], editorStatus: { editorActive: boolean; activeEditorName: string; enableEditorIntegration: boolean } = { editorActive: false, activeEditorName: '', enableEditorIntegration: true }): string { + // Group by category + const groups = new Map() + for (const v of variables) { + const cat = v.category || 'general' + if (!groups.has(cat)) groups.set(cat, []) + groups.get(cat)!.push(v) } -} -function renderAdminPage(variables: any[], categories: string[]): string { - const rows = variables.map((v: any) => ` - - {${v.key}} - ${escapeHtml(v.value)} - ${escapeHtml(v.description || '')} - ${v.category ? `${escapeHtml(v.category)}` : ''} - - - - + const categoryHtml = Array.from(groups.entries()).map(([cat, vars]) => ` +
+

+ + ${esc(cat)} (${vars.length}) +

+
+ + + ${vars.map(v => ` + + + + + + + `).join('')} + +
+ {${esc(v.key)}} + ${v.description ? `
${esc(v.description)}
` : ''} +
+
+ + + +
+
+ + + +
+
+
`).join('') - return ` - - - - - Global Variables - SonicJS - - - - -
-
-

Global Variables

-

- Manage dynamic content variables. Use {variable_key} syntax in rich text fields. -

+ return wrapAdminPage({ title: 'Global Variables', body: ` +
+ +
+
+
+ + + +

Global Variables

+
+

+ Dynamic content tokens. Use {variable_key} in rich text — resolved server-side on content read. +

+
+
${variables.length} variable${variables.length !== 1 ? 's' : ''}
-
- - - - - - - - - - - - ${rows || ''} - -
TokenValueDescriptionCategoryActive
No variables defined yet. Use the API to create variables.
+ +
+ ${categoryHtml || ` +
+
🔤
+

No variables yet

+

Create your first variable below to get started.

+
+ `}
-
-

API Reference

-
    -
  • GET /api/global-variables — List all variables
  • -
  • GET /api/global-variables/resolve — Get key→value map
  • -
  • POST /api/global-variables — Create variable
  • -
  • PUT /api/global-variables/:id — Update variable
  • -
  • DELETE /api/global-variables/:id — Delete variable
  • -
+ +
+

Add Variable

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
+
+

+ Rich Text Editor Integration + ${editorStatus.editorActive && editorStatus.enableEditorIntegration + ? `Active — ${esc(editorStatus.activeEditorName)}` + : 'Inactive' + } +

+

+ Adds a Var toolbar button to the rich text editor for inserting global variables as inline chips. Works with both Quill and TinyMCE. +

+
+ ${editorStatus.editorActive + ? `` + : '' + } +
+ ${!editorStatus.editorActive + ? `
+
+ +
+

No rich text editor plugin is active

+

+ To use variable insertion in the editor, enable either the Quill Editor or TinyMCE plugin from the + Plugins page. +

+
+
+
` + : '' + } +
+ + +
+ API Reference +
+
GET /api/global-variables — List all
+
GET /api/global-variables/resolve — Key→value map
+
POST /api/global-variables — Create { key, value, description, category }
+
PUT /api/global-variables/:id — Update { value, description, category, isActive }
+
DELETE /api/global-variables/:id — Delete
+
+
+ + + -
- -` -} -function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') + + +
+ ` }) } -// ============================================================================ -// Plugin Builder -// ============================================================================ +// ─── Plugin Builder ────────────────────────────────────────────────────────── -export function createGlobalVariablesPlugin(): Plugin { +export function createGlobalVariablesPlugin() { const builder = PluginBuilder.create({ name: 'global-variables', - version: '1.0.0-beta.1', - description: 'Dynamic content variables with inline token support for rich text fields', + version: '1.1.0', + description: 'Dynamic content variables with inline token support and CRUD admin page', }) builder.metadata({ - author: { name: 'SonicJS Team', email: 'team@sonicjs.com' }, + author: { name: 'SonicJS Community', email: 'community@sonicjs.com' }, license: 'MIT', compatibility: '^2.0.0', }) - // Database model - builder.addModel('GlobalVariable', { - tableName: 'global_variables', - schema: globalVariableSchema, - migrations: [globalVariablesMigration], - }) - - // API routes builder.addRoute('/api/global-variables', apiRoutes, { description: 'Global variables CRUD API', requiresAuth: true, priority: 50, }) - // Admin page builder.addRoute('/admin/global-variables', adminRoutes, { - description: 'Global variables admin page', + description: 'Global variables admin page with full CRUD', requiresAuth: true, priority: 50, }) - // Menu item builder.addMenuItem('Global Variables', '/admin/global-variables', { icon: 'variable', order: 45, permissions: ['global-variables:view'], }) - // Hook: resolve variables in content on read builder.addHook('content:read', async (data: any, context: any) => { try { const db = context?.context?.env?.DB if (!db || !data) return data - const variables = await getVariablesMap(db) if (variables.size === 0) return data - return resolveVariablesInObject(data, variables) } catch { - // Don't break content reads if resolution fails return data } }, { @@ -484,40 +707,156 @@ export function createGlobalVariablesPlugin(): Plugin { description: 'Resolve {variable_key} tokens in content data', }) - // Lifecycle builder.lifecycle({ - activate: async (ctx: any) => { - // Run migration to create table - try { - const db = ctx?.env?.DB - if (db) { - // Split migration into individual statements - const statements = globalVariablesMigration - .split(';') - .map((s: string) => s.trim()) - .filter((s: string) => s.length > 0) - - for (const statement of statements) { - await db.prepare(statement).run() - } - console.info('[GlobalVariables] Table created/verified') - } - } catch (error) { - console.error('[GlobalVariables] Migration error:', error) + install: async (ctx: any) => { + const db = ctx?.env?.DB + if (db) { + const statements = MIGRATION_SQL.split(';').map(s => s.trim()).filter(s => s.length > 0) + for (const stmt of statements) { await db.prepare(stmt).run() } + console.info('[GlobalVariables] Tables created') } + }, + activate: async () => { console.info('[GlobalVariables] Plugin activated') }, deactivate: async () => { - invalidateCache() - console.info('[GlobalVariables] Plugin deactivated') + invalidateVariablesCache() + console.info('[GlobalVariables] Plugin deactivated, cache cleared') + }, + uninstall: async (ctx: any) => { + const db = ctx?.env?.DB + if (db) { + await db.prepare('DROP TABLE IF EXISTS global_variables').run() + console.info('[GlobalVariables] Tables dropped') + } }, }) - return builder.build() as Plugin + return builder.build() } export const globalVariablesPlugin = createGlobalVariablesPlugin() -// Re-export resolver for direct use +// Export raw route handlers for direct mounting +export { apiRoutes as globalVariablesApiRoutes, adminRoutes as globalVariablesAdminRoutes } + +// Re-export resolver export { resolveVariables, resolveVariablesInObject } from './variable-resolver' -export { getVariablesMap, invalidateCache } + +// ─── Quill Blot Integration ───────────────────────────────────────────────── +// Self-contained Variable blot for the Quill editor. +// Returns injectable HTML (styles + scripts) that registers a VariableBlot +// and adds a "Var" toolbar button with a searchable picker dropdown. +// +// Depends on shared infrastructure from quill-shared.ts (picker, proxy, poller). +// The shared code is idempotent — safe to include from both plugins. + +export function getVariableBlotScript(): string { + return getSharedQuillStyles() + getSharedQuillScript() + ` + + ` + getQuillEnhancerPollerScript(); +} + +// ─── TinyMCE Integration ──────────────────────────────────────────────────── +// Self-contained Variable integration for the TinyMCE editor. +// Adds a "Var" toolbar button, renders variables as noneditable chips, +// serializes back to {key} syntax on save. + +export function getVariableTinyMceScript(): string { + return getSharedTinyMceStyles() + getTinyMcePluginScript({ + buttonName: 'sonicInsertVar', + buttonText: '\\u{1f524} Var', + buttonTooltip: 'Insert Global Variable', + pickerIcon: '\\u{1f524}', + pickerLabel: 'variables', + pickerApiUrl: '/api/global-variables?active=true', + renderItemJs: `function(item) { + return '{' + item.key + '}' + + '' + ((item.value || '') + '').substring(0, 40) + ''; + }`, + getSearchTextJs: `function(item) { + return item.key + ' ' + (item.value || '') + ' ' + (item.description || '') + ' ' + (item.category || ''); + }`, + onSelectJs: `function(editor, item) { + editor.insertContent('' + item.key + ' '); + }`, + }); +} diff --git a/packages/core/src/plugins/core-plugins/global-variables-plugin/manifest.json b/packages/core/src/plugins/core-plugins/global-variables-plugin/manifest.json index 23311a3a1..e2a5e72d3 100644 --- a/packages/core/src/plugins/core-plugins/global-variables-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/global-variables-plugin/manifest.json @@ -1,14 +1,14 @@ { "id": "global-variables", "name": "Global Variables", - "version": "1.0.0-beta.1", - "description": "Dynamic content variables that can be referenced as inline tokens in rich text fields. Supports {variable_key} syntax with server-side resolution.", - "author": "SonicJS Team", + "version": "1.1.0", + "description": "Dynamic content variables with inline token support. Manage key-value variables via admin UI and use {variable_key} syntax in rich text fields for server-side resolution. Includes full CRUD admin page.", + "author": "SonicJS Community", "homepage": "https://sonicjs.com/plugins/global-variables", "repository": "https://github.com/SonicJs-Org/sonicjs", "license": "MIT", "category": "content", - "tags": ["variables", "tokens", "dynamic-content", "rich-text", "globals"], + "tags": ["variables", "tokens", "dynamic-content", "rich-text", "globals", "admin"], "dependencies": [], "settings": { "enableResolution": true, @@ -17,11 +17,30 @@ }, "hooks": { "onActivate": "activate", - "onDeactivate": "deactivate" + "onDeactivate": "deactivate", + "onInstall": "install", + "onUninstall": "uninstall" }, - "routes": [], + "routes": [ + { + "path": "/api/global-variables", + "description": "CRUD API for global variables", + "requiresAuth": true + }, + { + "path": "/admin/global-variables", + "description": "Admin page for managing global variables", + "requiresAuth": true + } + ], "permissions": { - "global-variables:manage": "Manage global variables", - "global-variables:view": "View global variables" + "global-variables:manage": "Create, update, and delete global variables", + "global-variables:view": "View global variables and their values" + }, + "adminMenu": { + "label": "Global Variables", + "icon": "variable", + "path": "/admin/global-variables", + "order": 45 } } diff --git a/packages/core/src/plugins/core-plugins/index.ts b/packages/core/src/plugins/core-plugins/index.ts index 8fbb4fe61..be92aa6c3 100644 --- a/packages/core/src/plugins/core-plugins/index.ts +++ b/packages/core/src/plugins/core-plugins/index.ts @@ -28,6 +28,13 @@ export { oauthProvidersPlugin, createOAuthProvidersPlugin } from './oauth-provid export { OAuthService, BUILT_IN_PROVIDERS } from './oauth-providers/oauth-service' export { globalVariablesPlugin, createGlobalVariablesPlugin } from './global-variables-plugin' export { resolveVariables, resolveVariablesInObject } from './global-variables-plugin' +export { getVariableBlotScript, getVariableTinyMceScript } from './global-variables-plugin' +export { shortcodesPlugin, createShortcodesPlugin } from './shortcodes-plugin' +export { resolveShortcodes, resolveShortcodesInObject, registerShortcodeHandler } from './shortcodes-plugin' +export { getShortcodeBlotScript, getShortcodeTinyMceScript } from './shortcodes-plugin' +export { wrapAdminPage } from './_shared/admin-template' +export { getSharedQuillStyles, getSharedQuillScript, getQuillEnhancerPollerScript } from './_shared/quill-shared' +export { getSharedTinyMceStyles, getTinyMcePluginScript } from './_shared/tinymce-shared' export { securityAuditPlugin, createSecurityAuditPlugin } from './security-audit-plugin' export { SecurityAuditService, BruteForceDetector, securityAuditMiddleware } from './security-audit-plugin' export { userProfilesPlugin, createUserProfilesPlugin, defineUserProfile, getUserProfileConfig } from './user-profiles' @@ -52,6 +59,7 @@ export const CORE_PLUGIN_IDS = [ 'ai-search', 'oauth-providers', 'global-variables', + 'shortcodes', 'security-audit', 'user-profiles' ] as const diff --git a/packages/core/src/plugins/core-plugins/shortcodes-plugin/index.ts b/packages/core/src/plugins/core-plugins/shortcodes-plugin/index.ts new file mode 100644 index 000000000..bd1c52dd7 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/shortcodes-plugin/index.ts @@ -0,0 +1,742 @@ +/** + * Shortcodes Plugin + * + * Registered shortcode functions for dynamic content in rich text fields. + * Use [[shortcode_name param="value"]] syntax — resolved server-side on content read. + * + * Features: + * - Handler registry for custom shortcode functions + * - CRUD API for managing shortcode definitions + * - Admin page with handler status badges and live preview + * - content:read hook for automatic resolution + * - Built-in handlers: current_date, phone_link, cta_button, plan_count, provider_rating + * + * @see https://github.com/lane711/sonicjs/issues/719 (shortcodes mentioned as future extension) + */ + +import { Hono } from 'hono' +import { PluginBuilder } from '../../../sdk/plugin-builder' +import { wrapAdminPage } from '../_shared/admin-template' +import { + resolveShortcodesInObject, + resolveShortcodes, + getRegisteredHandlers, + hasHandler, +} from './shortcode-resolver' +import { + getSharedQuillStyles, + getSharedQuillScript, + getQuillEnhancerPollerScript, +} from '../_shared/quill-shared' +import { + getSharedTinyMceStyles, + getTinyMcePluginScript, +} from '../_shared/tinymce-shared' + +// Re-export for consumers +export { registerShortcodeHandler, getRegisteredHandlers } from './shortcode-resolver' + +// ─── Migration SQL ─────────────────────────────────────────────────────────── + +const MIGRATION_SQL = ` +CREATE TABLE IF NOT EXISTS shortcodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + description TEXT, + category TEXT DEFAULT 'general', + handler_key TEXT NOT NULL, + default_params TEXT DEFAULT '{}', + example_usage TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_shortcodes_name ON shortcodes(name); +CREATE INDEX IF NOT EXISTS idx_shortcodes_category ON shortcodes(category); +CREATE INDEX IF NOT EXISTS idx_shortcodes_active ON shortcodes(is_active); +` + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function formatShortcode(row: any) { + if (!row) return null + let defaultParams = {} + try { defaultParams = typeof row.default_params === 'string' ? JSON.parse(row.default_params) : (row.default_params || {}) } catch { /* */ } + return { + id: row.id, + name: row.name, + displayName: row.display_name, + description: row.description, + category: row.category, + handlerKey: row.handler_key, + defaultParams: defaultParams, + exampleUsage: row.example_usage, + isActive: row.is_active === 1 || row.is_active === true, + handlerRegistered: hasHandler(row.handler_key), + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function esc(s: string): string { + return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +// ─── API Routes ────────────────────────────────────────────────────────────── + +const apiRoutes = new Hono() + +apiRoutes.use('*', async (c: any, next: any) => { + try { + const db = c.env?.DB + if (db) { + const row = await db.prepare("SELECT status FROM plugins WHERE id = 'shortcodes' AND status = 'active'").first() + if (!row) return c.json({ error: 'Plugin not active' }, 404) + } + } catch { /* allow */ } + await next() +}) + +apiRoutes.get('/', async (c: any) => { + try { + const db = c.env.DB + const active = c.req.query('active') + const resolvable = c.req.query('resolvable') + let query = 'SELECT * FROM shortcodes' + const params: any[] = [] + if (active !== undefined) { query += ' WHERE is_active = ?'; params.push(active === 'true' ? 1 : 0) } + query += ' ORDER BY category ASC, name ASC' + + const { results } = await db.prepare(query).bind(...params).all() + let data = (results || []).map(formatShortcode) + + // Filter to only shortcodes with registered handlers (resolvable on content read) + if (resolvable === 'true') { + data = data.filter((sc: any) => sc.handlerRegistered) + } + + return c.json({ success: true, data }) + } catch { + return c.json({ success: false, error: 'Failed to fetch shortcodes' }, 500) + } +}) + +apiRoutes.get('/handlers/registered', async (c: any) => { + return c.json({ success: true, data: getRegisteredHandlers() }) +}) + +apiRoutes.post('/preview', async (c: any) => { + try { + const { text } = await c.req.json() + if (!text) return c.json({ error: 'text is required' }, 400) + const output = await resolveShortcodes(text) + return c.json({ success: true, data: { input: text, output } }) + } catch { + return c.json({ success: false, error: 'Failed to preview shortcode' }, 500) + } +}) + +apiRoutes.get('/:id', async (c: any) => { + try { + const result = await c.env.DB.prepare('SELECT * FROM shortcodes WHERE id = ?').bind(c.req.param('id')).first() + if (!result) return c.json({ error: 'Shortcode not found' }, 404) + return c.json({ success: true, data: formatShortcode(result) }) + } catch { + return c.json({ success: false, error: 'Failed to fetch shortcode' }, 500) + } +}) + +apiRoutes.post('/', async (c: any) => { + try { + const db = c.env.DB + const { name, display_name, displayName, description, handler_key, handlerKey, default_params, defaultParams, example_usage, exampleUsage, category } = await c.req.json() + const scName = name + const scHandlerKey = handler_key || handlerKey + const scDisplayName = display_name || displayName || scName + const scDefaultParams = default_params || defaultParams || {} + const scExampleUsage = example_usage || exampleUsage || '' + + if (!scName || !/^\w+$/.test(scName)) return c.json({ error: 'Name must be alphanumeric with underscores' }, 400) + if (!scHandlerKey) return c.json({ error: 'handler_key is required' }, 400) + + const existing = await db.prepare('SELECT id FROM shortcodes WHERE name = ?').bind(scName).first() + if (existing) return c.json({ error: `Shortcode "${scName}" already exists` }, 409) + + await db.prepare( + 'INSERT INTO shortcodes (name, display_name, description, handler_key, default_params, example_usage, category) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).bind(scName, scDisplayName, description || '', scHandlerKey, JSON.stringify(scDefaultParams), scExampleUsage, category || 'general').run() + + const created = await db.prepare('SELECT * FROM shortcodes WHERE name = ?').bind(scName).first() + return c.json({ success: true, data: formatShortcode(created) }, 201) + } catch { + return c.json({ success: false, error: 'Failed to create shortcode' }, 500) + } +}) + +apiRoutes.put('/:id', async (c: any) => { + try { + const db = c.env.DB + const id = c.req.param('id') + const existing = await db.prepare('SELECT * FROM shortcodes WHERE id = ?').bind(id).first() + if (!existing) return c.json({ error: 'Shortcode not found' }, 404) + + const body = await c.req.json() + const updates: string[] = [] + const params: any[] = [] + + if (body.display_name !== undefined || body.displayName !== undefined) { updates.push('display_name = ?'); params.push(body.display_name || body.displayName) } + if (body.description !== undefined) { updates.push('description = ?'); params.push(body.description) } + if (body.handler_key !== undefined || body.handlerKey !== undefined) { updates.push('handler_key = ?'); params.push(body.handler_key || body.handlerKey) } + if (body.default_params !== undefined || body.defaultParams !== undefined) { updates.push('default_params = ?'); params.push(JSON.stringify(body.default_params || body.defaultParams)) } + if (body.example_usage !== undefined || body.exampleUsage !== undefined) { updates.push('example_usage = ?'); params.push(body.example_usage || body.exampleUsage) } + if (body.category !== undefined) { updates.push('category = ?'); params.push(body.category) } + if (body.isActive !== undefined || body.is_active !== undefined) { updates.push('is_active = ?'); params.push((body.isActive ?? body.is_active) ? 1 : 0) } + + if (updates.length === 0) return c.json({ error: 'No fields to update' }, 400) + updates.push('updated_at = strftime(\'%s\', \'now\')') + params.push(id) + await db.prepare(`UPDATE shortcodes SET ${updates.join(', ')} WHERE id = ?`).bind(...params).run() + + const updated = await db.prepare('SELECT * FROM shortcodes WHERE id = ?').bind(id).first() + return c.json({ success: true, data: formatShortcode(updated) }) + } catch { + return c.json({ success: false, error: 'Failed to update shortcode' }, 500) + } +}) + +apiRoutes.delete('/:id', async (c: any) => { + try { + const db = c.env.DB + const id = c.req.param('id') + const existing = await db.prepare('SELECT id FROM shortcodes WHERE id = ?').bind(id).first() + if (!existing) return c.json({ error: 'Shortcode not found' }, 404) + await db.prepare('DELETE FROM shortcodes WHERE id = ?').bind(id).run() + return c.json({ success: true }) + } catch { + return c.json({ success: false, error: 'Failed to delete shortcode' }, 500) + } +}) + +// ─── Admin Page ────────────────────────────────────────────────────────────── + +const adminRoutes = new Hono() + +adminRoutes.use('*', async (c: any, next: any) => { + try { + const db = c.env?.DB + if (db) { + const row = await db.prepare("SELECT status FROM plugins WHERE id = 'shortcodes' AND status = 'active'").first() + if (!row) return c.html('

Plugin not active

Enable the Shortcodes plugin from Plugins.

', 404) + } + } catch { /* allow */ } + await next() +}) + +adminRoutes.get('/', async (c: any) => { + const db = c.env.DB + let shortcodes: any[] = [] + try { + const { results } = await db.prepare('SELECT * FROM shortcodes ORDER BY category ASC, name ASC').all() + shortcodes = (results || []).map(formatShortcode) + } catch { /* table may not exist */ } + + // Fetch editor integration status + let editorActive = false + let activeEditorName = '' + let enableEditorIntegration = true + try { + const qeRow = await db.prepare("SELECT status FROM plugins WHERE (id = 'quill-editor' OR name = 'quill-editor') AND status = 'active'").first() + const tmRow = await db.prepare("SELECT status FROM plugins WHERE (id = 'tinymce-plugin' OR name = 'tinymce-plugin') AND status = 'active'").first() + if (qeRow) { editorActive = true; activeEditorName = 'Quill Editor' } + else if (tmRow) { editorActive = true; activeEditorName = 'TinyMCE' } + const scRow = await db.prepare("SELECT settings FROM plugins WHERE id = 'shortcodes'").first() as any + if (scRow?.settings) { + const settings = typeof scRow.settings === 'string' ? JSON.parse(scRow.settings) : scRow.settings + enableEditorIntegration = settings.enableEditorIntegration !== false + } + } catch { /* ignore */ } + + return c.html(renderAdminPage(shortcodes, { editorActive, activeEditorName, enableEditorIntegration })) +}) + +// HTMX: create +adminRoutes.post('/', async (c: any) => { + const db = c.env.DB + const form = await c.req.parseBody() + const name = (form.name as string || '').trim() + const displayName = (form.display_name as string || name).trim() + const handlerKey = (form.handler_key as string || '').trim() + const category = (form.category as string || 'general').trim() + const description = (form.description as string || '').trim() + const exampleUsage = (form.example_usage as string || '').trim() + + if (!name || !/^\w+$/.test(name)) { + return c.html('
Name must be alphanumeric with underscores
') + } + if (!handlerKey) { + return c.html('
Handler is required
') + } + + await db.prepare( + 'INSERT OR IGNORE INTO shortcodes (name, display_name, description, handler_key, default_params, example_usage, category) VALUES (?, ?, ?, ?, \'{}\', ?, ?)' + ).bind(name, displayName, description, handlerKey, exampleUsage, category).run() + + c.header('HX-Redirect', '/admin/shortcodes') + return c.body(null, 204) +}) + +// Toggle editor integration setting +adminRoutes.post('/settings/editor-integration', async (c: any) => { + const db = c.env.DB + try { + const row = await db.prepare("SELECT settings FROM plugins WHERE id = 'shortcodes'").first() as any + const settings = row?.settings ? (typeof row.settings === 'string' ? JSON.parse(row.settings) : row.settings) : {} + settings.enableEditorIntegration = !settings.enableEditorIntegration + await db.prepare("UPDATE plugins SET settings = ? WHERE id = 'shortcodes'").bind(JSON.stringify(settings)).run() + return c.json({ success: true, enableEditorIntegration: settings.enableEditorIntegration }) + } catch { + return c.json({ success: false, error: 'Failed to update setting' }, 500) + } +}) + +// HTMX: delete +adminRoutes.delete('/:id', async (c: any) => { + await c.env.DB.prepare('DELETE FROM shortcodes WHERE id = ?').bind(c.req.param('id')).run() + return c.html('') +}) + +// ─── Admin Page Template ───────────────────────────────────────────────────── + +function renderAdminPage(shortcodes: any[], editorStatus: { editorActive: boolean; activeEditorName: string; enableEditorIntegration: boolean } = { editorActive: false, activeEditorName: '', enableEditorIntegration: true }): string { + const groups = new Map() + for (const sc of shortcodes) { + const cat = sc.category || 'general' + if (!groups.has(cat)) groups.set(cat, []) + groups.get(cat)!.push(sc) + } + + const handlers = getRegisteredHandlers() + + const categoryHtml = Array.from(groups.entries()).map(([cat, scs]) => ` +
+

+ + ${esc(cat)} (${scs.length}) +

+
+ ${scs.map(sc => ` +
+
+
+
+ ${esc(sc.displayName || sc.name)} + [[${esc(sc.name)}]] + + ${sc.isActive ? 'Active' : 'Off'} + + ${sc.handlerRegistered + ? 'Resolvable' + : 'No handler' + } +
+ ${sc.description ? `

${esc(sc.description)}

` : ''} + ${sc.exampleUsage ? `${esc(sc.exampleUsage)}` : ''} +
+ +
+
+ `).join('')} +
+
+ `).join('') + + const handlerOptions = handlers.map(h => ``).join('') + + return wrapAdminPage({ title: 'Shortcodes', body: ` +
+
+
+
+ + + +

Shortcodes

+
+

+ Inline content functions. Use [[shortcode_name param="value"]] in rich text — each resolves to an inline HTML string on content read. Only shortcodes with registered handlers are available in the Quill editor picker. +

+
+
${shortcodes.length} shortcode${shortcodes.length !== 1 ? 's' : ''} · ${handlers.length} handler${handlers.length !== 1 ? 's' : ''}
+
+ +
+ ${categoryHtml || ` +
+
+

No shortcodes yet

+

Register a shortcode below.

+
+ `} +
+ + +
+

Register Shortcode

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+

Preview

+
+ + +
+ +
+ + + + +
+
+
+

+ Rich Text Editor Integration + ${editorStatus.editorActive && editorStatus.enableEditorIntegration + ? `Active — ${esc(editorStatus.activeEditorName)}` + : 'Inactive' + } +

+

+ Adds an SC toolbar button to the rich text editor for inserting shortcodes as inline chips. Works with both Quill and TinyMCE. +

+
+ ${editorStatus.editorActive + ? `` + : '' + } +
+ ${!editorStatus.editorActive + ? `
+
+ +
+

No rich text editor plugin is active

+

+ To use shortcode insertion in the editor, enable either the Quill Editor or TinyMCE plugin from the + Plugins page. +

+
+
+
` + : '' + } +
+ + + +
+ API Reference +
+
GET /api/shortcodes — List all
+
GET /api/shortcodes/handlers/registered — Handler keys
+
POST /api/shortcodes/preview — Live preview
+
POST /api/shortcodes — Create
+
PUT /api/shortcodes/:id — Update
+
DELETE /api/shortcodes/:id — Delete
+
+
+
+ ` }) +} + +// ─── Plugin Builder ────────────────────────────────────────────────────────── + +export function createShortcodesPlugin() { + const builder = PluginBuilder.create({ + name: 'shortcodes', + version: '1.0.0', + description: 'Registered shortcode functions for dynamic content with [[shortcode]] syntax', + }) + + builder.metadata({ + author: { name: 'SonicJS Community', email: 'community@sonicjs.com' }, + license: 'MIT', + compatibility: '^2.0.0', + }) + + builder.addRoute('/api/shortcodes', apiRoutes, { + description: 'Shortcodes CRUD API + preview + handler list', + requiresAuth: true, + priority: 50, + }) + + builder.addRoute('/admin/shortcodes', adminRoutes, { + description: 'Shortcodes admin page with CRUD and preview', + requiresAuth: true, + priority: 50, + }) + + builder.addMenuItem('Shortcodes', '/admin/shortcodes', { + icon: 'bolt', + order: 46, + permissions: ['shortcodes:view'], + }) + + builder.addHook('content:read', async (data: any, context: any) => { + try { + if (!data) return data + return resolveShortcodesInObject(data, context) + } catch { + return data + } + }, { + priority: 60, // Run after global-variables (50) + description: 'Resolve [[shortcode]] tokens in content data', + }) + + builder.lifecycle({ + install: async (ctx: any) => { + const db = ctx?.env?.DB + if (db) { + const statements = MIGRATION_SQL.split(';').map(s => s.trim()).filter(s => s.length > 0) + for (const stmt of statements) { await db.prepare(stmt).run() } + console.info('[Shortcodes] Tables created') + } + }, + activate: async () => { + console.info('[Shortcodes] Plugin activated') + }, + deactivate: async () => { + console.info('[Shortcodes] Plugin deactivated') + }, + uninstall: async (ctx: any) => { + const db = ctx?.env?.DB + if (db) { + await db.prepare('DROP TABLE IF EXISTS shortcodes').run() + console.info('[Shortcodes] Tables dropped') + } + }, + }) + + return builder.build() +} + +export const shortcodesPlugin = createShortcodesPlugin() + +// Export raw route handlers for direct mounting +export { apiRoutes as shortcodesApiRoutes, adminRoutes as shortcodesAdminRoutes } + +// Re-export resolver +export { resolveShortcodes, resolveShortcodesInObject } from './shortcode-resolver' + +// ─── Quill Blot Integration ───────────────────────────────────────────────── +// Self-contained Shortcode blot for the Quill editor. +// Returns injectable HTML (styles + scripts) that registers a ShortcodeBlot +// and adds a "SC" toolbar button with a searchable picker dropdown. +// +// Depends on shared infrastructure from quill-shared.ts (picker, proxy, poller). +// The shared code is idempotent — safe to include from both plugins. + +export function getShortcodeBlotScript(): string { + return getSharedQuillStyles() + getSharedQuillScript() + ` + + ` + getQuillEnhancerPollerScript(); +} + +// ─── TinyMCE Integration ──────────────────────────────────────────────────── +// Self-contained Shortcode integration for the TinyMCE editor. +// Adds a "SC" toolbar button, renders shortcodes as noneditable chips, +// serializes back to [[name params]] syntax on save. + +export function getShortcodeTinyMceScript(): string { + return getSharedTinyMceStyles() + getTinyMcePluginScript({ + buttonName: 'sonicInsertSC', + buttonText: '\\u26A1 SC', + buttonTooltip: 'Insert Shortcode', + pickerIcon: '\\u26A1', + pickerLabel: 'shortcodes', + pickerApiUrl: '/api/shortcodes?active=true&resolvable=true', + renderItemJs: `function(item) { + return '[[' + item.name + ']]' + + '' + (item.display_name || item.name) + ''; + }`, + getSearchTextJs: `function(item) { + return item.name + ' ' + (item.display_name || '') + ' ' + (item.description || '') + ' ' + (item.category || ''); + }`, + onSelectJs: `function(editor, item) { + var params = ''; + try { + var dp = typeof item.default_params === 'string' ? JSON.parse(item.default_params) : (item.default_params || {}); + var pp = []; + Object.keys(dp).forEach(function(k) { pp.push(k + '="' + dp[k] + '"'); }); + if (pp.length) params = pp.join(' '); + } catch(ex) {} + var label = item.name + (params ? ' ' + params : ''); + var pa = params ? ' data-sc-params="' + params.replace(/"/g, '&quot;') + '"' : ''; + editor.insertContent('' + label + ' '); + }`, + }); +} diff --git a/packages/core/src/plugins/core-plugins/shortcodes-plugin/manifest.json b/packages/core/src/plugins/core-plugins/shortcodes-plugin/manifest.json new file mode 100644 index 000000000..340166499 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/shortcodes-plugin/manifest.json @@ -0,0 +1,45 @@ +{ + "id": "shortcodes", + "name": "Shortcodes", + "version": "1.0.0", + "description": "Registered shortcode functions for dynamic content. Use [[shortcode_name param=\"value\"]] syntax in rich text fields for server-side resolution. Includes handler registry, CRUD admin, and live preview.", + "author": "SonicJS Community", + "homepage": "https://sonicjs.com/plugins/shortcodes", + "repository": "https://github.com/SonicJs-Org/sonicjs", + "license": "MIT", + "category": "content", + "tags": ["shortcodes", "dynamic-content", "rich-text", "functions", "templates"], + "dependencies": [], + "settings": { + "enableResolution": true, + "cacheEnabled": true + }, + "hooks": { + "onActivate": "activate", + "onDeactivate": "deactivate", + "onInstall": "install", + "onUninstall": "uninstall" + }, + "routes": [ + { + "path": "/api/shortcodes", + "description": "Shortcodes CRUD API + preview + handler list", + "requiresAuth": true + }, + { + "path": "/admin/shortcodes", + "description": "Admin page for managing shortcodes", + "requiresAuth": true + } + ], + "permissions": { + "shortcodes:manage": "Create, update, and delete shortcodes", + "shortcodes:view": "View shortcodes and their definitions" + }, + "adminMenu": { + "label": "Shortcodes", + "icon": "bolt", + "path": "/admin/shortcodes", + "order": 46 + } +} diff --git a/packages/core/src/plugins/core-plugins/shortcodes-plugin/shortcode-resolver.ts b/packages/core/src/plugins/core-plugins/shortcodes-plugin/shortcode-resolver.ts new file mode 100644 index 000000000..262fd737e --- /dev/null +++ b/packages/core/src/plugins/core-plugins/shortcodes-plugin/shortcode-resolver.ts @@ -0,0 +1,181 @@ +/** + * Shortcode Resolver + * + * Scans strings for [[shortcode_name param="value"]] tokens and replaces them + * by calling registered handler functions. + * + * Token syntax: [[name]] or [[name param1="value1" param2="value2"]] + * Unresolved shortcodes (no handler) are left as-is. + */ + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type ShortcodeHandler = ( + params: Record, + context?: any +) => string | Promise + +// ─── Handler Registry ──────────────────────────────────────────────────────── + +const handlerRegistry = new Map() + +/** + * Register a shortcode handler function. + * Call this at module load time to make handlers available. + */ +export function registerShortcodeHandler(key: string, handler: ShortcodeHandler): void { + handlerRegistry.set(key, handler) +} + +/** + * Get all registered handler keys. + */ +export function getRegisteredHandlers(): string[] { + return Array.from(handlerRegistry.keys()) +} + +/** + * Check if a handler is registered. + */ +export function hasHandler(key: string): boolean { + return handlerRegistry.has(key) +} + +// ─── Parser ────────────────────────────────────────────────────────────────── + +const SHORTCODE_PATTERN = /\[\[(\w+)([^\]]*)\]\]/g + +/** + * Parse shortcode parameters from a string like: param1="value1" param2="value2" + */ +export function parseShortcodeParams(paramStr: string): Record { + const params: Record = {} + const regex = /(\w+)="([^"]*)"/g + let match + while ((match = regex.exec(paramStr)) !== null) { + params[match[1]] = match[2] + } + return params +} + +// ─── Resolver ──────────────────────────────────────────────────────────────── + +/** + * Resolve all [[shortcode]] tokens in a string. + * Calls registered handlers and replaces tokens with output. + * Unknown shortcodes are left as-is. + */ +export async function resolveShortcodes( + text: string, + context?: any +): Promise { + if (!text) return text + // Quick check before running regex + if (!text.includes('[[')) return text + + SHORTCODE_PATTERN.lastIndex = 0 + const replacements: Array<{ match: string; replacement: string }> = [] + + let m + while ((m = SHORTCODE_PATTERN.exec(text)) !== null) { + const name = m[1] + const paramStr = m[2] + const params = parseShortcodeParams(paramStr) + const handler = handlerRegistry.get(name) + + if (handler) { + try { + const result = await handler(params, context) + // Handlers must return strings (inline HTML or plain text). + // This is the contract: shortcodes resolve to content that can + // live inside rich text — not components, not objects. + if (typeof result === 'string') { + replacements.push({ match: m[0], replacement: result }) + } else { + replacements.push({ match: m[0], replacement: `` }) + } + } catch { + replacements.push({ match: m[0], replacement: `` }) + } + } + // Unknown shortcodes (no handler) left as-is + } + + let result = text + for (const r of replacements) { + result = result.replace(r.match, r.replacement) + } + return result +} + +/** + * Recursively resolve shortcodes in an object's string values. + */ +export async function resolveShortcodesInObject( + obj: any, + context?: any +): Promise { + if (!obj) return obj + + if (typeof obj === 'string') { + return resolveShortcodes(obj, context) + } + + if (Array.isArray(obj)) { + return Promise.all(obj.map(item => resolveShortcodesInObject(item, context))) + } + + if (typeof obj === 'object') { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = await resolveShortcodesInObject(value, context) + } + return result + } + + return obj +} + +// ─── Built-in Handlers ─────────────────────────────────────────────────────── + +registerShortcodeHandler('current_date', (params) => { + const now = new Date() + const format = params.format || 'MMMM D, YYYY' + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + const monthsShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + return format + .replace('YYYY', String(now.getFullYear())) + .replace('YY', String(now.getFullYear()).slice(-2)) + .replace('MMMM', months[now.getMonth()]) + .replace('MMM', monthsShort[now.getMonth()]) + .replace('MM', String(now.getMonth() + 1).padStart(2, '0')) + .replace('DD', String(now.getDate()).padStart(2, '0')) + .replace('D', String(now.getDate())) +}) + +registerShortcodeHandler('phone_link', (params) => { + const number = params.number || '' + const digits = number.replace(/\D/g, '') + return `${number}` +}) + +registerShortcodeHandler('cta_button', (params) => { + const text = params.text || 'Learn More' + const url = params.url || '/' + const style = params.style || 'primary' + const colors = style === 'primary' + ? 'bg-blue-600 hover:bg-blue-700 text-white' + : 'bg-zinc-200 hover:bg-zinc-300 text-zinc-900' + return `${text}` +}) + +registerShortcodeHandler('plan_count', (params) => { + const type = params.type || 'residential' + return `1,000+` +}) + +registerShortcodeHandler('provider_rating', (params) => { + const format = params.format || 'stars' + const display = format === 'numeric' ? '4.2/5' : '★★★★☆' + return `${display}` +})