- Manage dynamic content variables. Use {variable_key} syntax in rich text fields.
-
+
+
+
+
+
+
+
+
Global Variables
+
+
+ Dynamic content tokens. Use {variable_key} in rich text — resolved server-side on content read.
+
+
+
${variables.length} variable${variables.length !== 1 ? 's' : ''}
-
-
-
-
- | Token |
- Value |
- Description |
- Category |
- Active |
-
-
-
- ${rows || '| 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
-
+
+
+
+
+
+
+
+
+ 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: `
+
+
+
+
+
+ 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.
+
+ `}
+
+
+
+
+
+
+
+
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, '"') + '"' : '';
+ 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}`
+})