domma-cms 0.6.15 → 0.6.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/app.js +4 -4
  3. package/admin/js/config/sidebar-config.js +1 -1
  4. package/admin/js/lib/markdown-toolbar.js +14 -12
  5. package/admin/js/views/collection-editor.js +5 -3
  6. package/admin/js/views/collections.js +1 -1
  7. package/admin/js/views/page-editor.js +27 -27
  8. package/config/plugins.json +20 -0
  9. package/config/site.json +1 -1
  10. package/package.json +2 -2
  11. package/plugins/analytics/stats.json +1 -1
  12. package/plugins/contacts/admin/templates/contacts.html +126 -0
  13. package/plugins/contacts/admin/views/contacts.js +710 -0
  14. package/plugins/contacts/config.js +6 -0
  15. package/plugins/contacts/data/contacts.json +20 -0
  16. package/plugins/contacts/plugin.js +351 -0
  17. package/plugins/contacts/plugin.json +23 -0
  18. package/plugins/docs/admin/templates/docs.html +69 -0
  19. package/plugins/docs/admin/views/docs.js +276 -0
  20. package/plugins/docs/config.js +8 -0
  21. package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +11 -0
  22. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +11 -0
  23. package/plugins/docs/data/folders.json +9 -0
  24. package/plugins/docs/data/templates.json +1 -0
  25. package/plugins/docs/plugin.js +375 -0
  26. package/plugins/docs/plugin.json +23 -0
  27. package/plugins/job-board/admin/templates/application-detail.html +40 -0
  28. package/plugins/job-board/admin/templates/applications.html +10 -0
  29. package/plugins/job-board/admin/templates/companies.html +24 -0
  30. package/plugins/job-board/admin/templates/dashboard.html +36 -0
  31. package/plugins/job-board/admin/templates/job-editor.html +17 -0
  32. package/plugins/job-board/admin/templates/jobs.html +15 -0
  33. package/plugins/job-board/admin/templates/profile.html +17 -0
  34. package/plugins/job-board/admin/views/application-detail.js +62 -0
  35. package/plugins/job-board/admin/views/applications.js +47 -0
  36. package/plugins/job-board/admin/views/companies.js +104 -0
  37. package/plugins/job-board/admin/views/dashboard.js +88 -0
  38. package/plugins/job-board/admin/views/job-editor.js +86 -0
  39. package/plugins/job-board/admin/views/jobs.js +53 -0
  40. package/plugins/job-board/admin/views/profile.js +47 -0
  41. package/plugins/job-board/config.js +6 -0
  42. package/plugins/job-board/plugin.js +466 -0
  43. package/plugins/job-board/plugin.json +40 -0
  44. package/plugins/job-board/schemas/jb-agent-companies.json +17 -0
  45. package/plugins/job-board/schemas/jb-applications.json +20 -0
  46. package/plugins/job-board/schemas/jb-candidate-profiles.json +20 -0
  47. package/plugins/job-board/schemas/jb-companies.json +21 -0
  48. package/plugins/job-board/schemas/jb-jobs.json +23 -0
  49. package/plugins/notes/admin/templates/notes.html +92 -0
  50. package/plugins/notes/admin/views/notes.js +304 -0
  51. package/plugins/notes/config.js +6 -0
  52. package/plugins/notes/data/notes.json +1 -0
  53. package/plugins/notes/plugin.js +177 -0
  54. package/plugins/notes/plugin.json +23 -0
  55. package/plugins/todo/admin/templates/todo.html +164 -0
  56. package/plugins/todo/admin/views/todo.js +328 -0
  57. package/plugins/todo/config.js +7 -0
  58. package/plugins/todo/data/todos.json +1 -0
  59. package/plugins/todo/plugin.js +155 -0
  60. package/plugins/todo/plugin.json +23 -0
  61. package/server/routes/api/auth.js +2 -0
  62. package/server/routes/api/collections.js +59 -0
  63. package/server/routes/api/forms.js +3 -0
  64. package/server/routes/api/plugins.js +9 -1
  65. package/server/routes/api/settings.js +16 -1
  66. package/server/routes/public.js +2 -0
  67. package/server/services/markdown.js +155 -8
  68. package/server/services/plugins.js +33 -2
  69. package/plugins/example-analytics/admin/templates/analytics.html +0 -10
  70. package/plugins/example-analytics/admin/views/analytics.js +0 -51
  71. package/plugins/example-analytics/config.js +0 -6
  72. package/plugins/example-analytics/plugin.js +0 -58
  73. package/plugins/example-analytics/plugin.json +0 -45
  74. package/plugins/example-analytics/public/inject-body.html +0 -14
  75. package/plugins/example-analytics/public/inject-head.html +0 -1
  76. package/plugins/example-analytics/stats.json +0 -24
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import crypto from 'node:crypto';
11
11
  import {config, getConfig} from '../../config.js';
12
+ import {hooks} from '../../services/hooks.js';
12
13
  import {authenticate, getPermissionsForRole} from '../../middleware/auth.js';
13
14
  import {GROUP_ORDER, REGISTRY} from '../../services/permissionRegistry.js';
14
15
  import {
@@ -93,6 +94,7 @@ export async function authRoutes(fastify) {
93
94
  await touchLastLogin(user.id);
94
95
 
95
96
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
97
+ hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
96
98
  const { token, refreshToken } = signTokens(fastify, safeUser);
97
99
  return { token, refreshToken, user: safeUser };
98
100
  });
@@ -43,6 +43,7 @@ import {getRoleLevel, invalidate as invalidateRoles} from '../../services/roles.
43
43
  import {getConfig, saveConfig} from '../../config.js';
44
44
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
45
45
  import {ensureFormForCollection} from '../../services/forms.js';
46
+ import {hooks} from '../../services/hooks.js';
46
47
 
47
48
  const ALL_PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
48
49
 
@@ -206,6 +207,7 @@ export async function collectionsRoutes(fastify) {
206
207
  source: 'admin'
207
208
  });
208
209
  if (request.params.slug === 'roles') await invalidateRoles();
210
+ hooks.emit('collection:entryCreated', { slug: request.params.slug, entryId: entry.id, data: entry.data, userId: user?.id || null });
209
211
  return reply.status(201).send(entry);
210
212
  } catch (err) {
211
213
  return reply.status(400).send({ error: err.message });
@@ -216,6 +218,7 @@ export async function collectionsRoutes(fastify) {
216
218
  try {
217
219
  const entry = await updateEntry(request.params.slug, request.params.id, request.body?.data || {});
218
220
  if (request.params.slug === 'roles') await invalidateRoles();
221
+ hooks.emit('collection:entryUpdated', { slug: request.params.slug, entryId: entry.id, data: entry.data, userId: request.user?.id || null });
219
222
  return entry;
220
223
  } catch (err) {
221
224
  const status = err.message === 'Entry not found' ? 404 : 400;
@@ -233,6 +236,7 @@ export async function collectionsRoutes(fastify) {
233
236
  try {
234
237
  await deleteEntry(request.params.slug, request.params.id);
235
238
  if (request.params.slug === 'roles') await invalidateRoles();
239
+ hooks.emit('collection:entryDeleted', { slug: request.params.slug, entryId: request.params.id, userId: request.user?.id || null });
236
240
  return { success: true };
237
241
  } catch (err) {
238
242
  return reply.status(404).send({ error: err.message });
@@ -249,6 +253,61 @@ export async function collectionsRoutes(fastify) {
249
253
  }
250
254
  });
251
255
 
256
+ // -------------------------------------------------------------------------
257
+ // Storage migration
258
+ // -------------------------------------------------------------------------
259
+
260
+ fastify.post('/collections/:slug/migrate-storage', canUpdate, async (request, reply) => {
261
+ const {slug} = request.params;
262
+ const {storage} = request.body || {};
263
+
264
+ if (!storage?.adapter) {
265
+ return reply.status(400).send({error: 'storage.adapter is required'});
266
+ }
267
+
268
+ const schema = await getCollection(slug);
269
+ if (!schema) return reply.status(404).send({error: 'Collection not found'});
270
+
271
+ const sourceAdapter = schema.storage?.adapter || 'file';
272
+ if (sourceAdapter === storage.adapter) {
273
+ return reply.status(400).send({error: 'Source and target adapters are the same'});
274
+ }
275
+
276
+ // Step 1: read all existing entries from current adapter BEFORE schema change
277
+ const {entries} = await listEntries(slug, {limit: 1000000, sort: 'createdAt', order: 'asc'});
278
+
279
+ // Step 2: update schema to new adapter (also invalidates the adapter cache)
280
+ await updateCollection(slug, {...schema, storage});
281
+
282
+ // Step 3: insert all entries into the new adapter
283
+ let migrated = 0;
284
+ for (const entry of entries) {
285
+ try {
286
+ await createEntry(slug, entry.data, {
287
+ createdBy: entry.meta?.createdBy || null,
288
+ source: 'migration'
289
+ });
290
+ migrated++;
291
+ } catch (err) {
292
+ fastify.log.warn(`[migrate-storage] Entry ${entry.id} skipped: ${err.message}`);
293
+ }
294
+ }
295
+
296
+ // Step 4: if migrating away from file storage, archive the old data.json
297
+ if (sourceAdapter === 'file') {
298
+ try {
299
+ const {rename} = await import('fs/promises');
300
+ const {join} = await import('path');
301
+ const dataPath = join(process.cwd(), 'content', 'collections', slug, 'data.json');
302
+ await rename(dataPath, dataPath + '.bak');
303
+ } catch {
304
+ // data.json may not exist or rename already done
305
+ }
306
+ }
307
+
308
+ return {migrated, total: entries.length};
309
+ });
310
+
252
311
  // -------------------------------------------------------------------------
253
312
  // Export / Import
254
313
  // -------------------------------------------------------------------------
@@ -33,6 +33,7 @@ import {
33
33
  } from '../../services/collections.js';
34
34
  import {getConfig} from '../../config.js';
35
35
  import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
36
+ import {hooks} from '../../services/hooks.js';
36
37
 
37
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
39
  const ROOT = path.resolve(__dirname, '..', '..', '..');
@@ -453,6 +454,8 @@ export async function formsRoutes(fastify) {
453
454
  }
454
455
  }
455
456
 
457
+ hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
458
+
456
459
  return {
457
460
  ok: true,
458
461
  message: settings.successMessage || 'Thank you for your submission.',
@@ -5,7 +5,7 @@
5
5
  * GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
6
6
  */
7
7
  import { authenticate, requireAdmin, requirePermission } from '../../middleware/auth.js';
8
- import { discoverPlugins, getPluginStates, savePluginState, getAdminPluginConfig } from '../../services/plugins.js';
8
+ import { discoverPlugins, getPluginStates, savePluginState, getAdminPluginConfig, runLifecycleHook } from '../../services/plugins.js';
9
9
 
10
10
  export async function pluginsRoutes(fastify) {
11
11
  const canRead = { preHandler: [authenticate, requirePermission('plugins', 'read')] };
@@ -38,7 +38,15 @@ export async function pluginsRoutes(fastify) {
38
38
  const manifest = manifests.find(m => m.name === name);
39
39
  if (!manifest) return reply.status(404).send({ error: 'Plugin not found' });
40
40
 
41
+ const prevState = getPluginStates()[name] || {};
41
42
  savePluginState(name, { enabled: !!enabled, settings: settings || {} });
43
+
44
+ if (!prevState.enabled && !!enabled) {
45
+ await runLifecycleHook(name, 'onEnable', fastify);
46
+ } else if (prevState.enabled && !enabled) {
47
+ await runLifecycleHook(name, 'onDisable', fastify);
48
+ }
49
+
42
50
  return { success: true };
43
51
  });
44
52
 
@@ -13,7 +13,8 @@ import {fileURLToPath} from 'url';
13
13
 
14
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
15
  const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
16
- const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
16
+ const CONNECTIONS_FILE = path.resolve(__dirname, '../../../config/connections.json');
17
+ const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
17
18
 
18
19
  export async function settingsRoutes(fastify) {
19
20
  const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
@@ -79,6 +80,20 @@ export async function settingsRoutes(fastify) {
79
80
  }
80
81
  });
81
82
 
83
+ // GET /api/settings/db-status — returns whether MongoDB connections are configured
84
+ fastify.get('/settings/db-status', canRead, async () => {
85
+ try {
86
+ const raw = await fs.readFile(CONNECTIONS_FILE, 'utf8');
87
+ const connections = JSON.parse(raw);
88
+ const names = Object.keys(connections).filter(k =>
89
+ connections[k]?.type === 'mongodb' && connections[k]?.uri
90
+ );
91
+ return {configured: names.length > 0, connections: names};
92
+ } catch {
93
+ return {configured: false, connections: []};
94
+ }
95
+ });
96
+
82
97
  // GET /api/settings/custom-css — return current CSS as JSON
83
98
  fastify.get('/settings/custom-css', canUpdate, async () => {
84
99
  try {
@@ -7,6 +7,7 @@
7
7
  import {getPage} from '../services/content.js';
8
8
  import {renderPage} from '../services/renderer.js';
9
9
  import {getRoleLevel} from '../services/roles.js';
10
+ import {hooks} from '../services/hooks.js';
10
11
 
11
12
  /**
12
13
  * Escape user-controlled strings before interpolating into HTML.
@@ -83,6 +84,7 @@ export async function publicRoutes(fastify) {
83
84
  }
84
85
 
85
86
  const html = await renderPage(page);
87
+ hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
86
88
  return reply.type('text/html').send(html);
87
89
  });
88
90
  }
@@ -96,6 +96,49 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols)
96
96
  return `<div class="${wrapperClass}">\n${items.join('\n')}\n</div>`;
97
97
  }
98
98
 
99
+ /**
100
+ * Process [block template="name" field1="val" field2="val" /] shortcodes.
101
+ * Loads the named block template and substitutes {{placeholders}} with
102
+ * the supplied attribute values. Renders a single block instance.
103
+ *
104
+ * @param {string} content
105
+ * @returns {Promise<string>}
106
+ */
107
+ async function processStaticBlocks(content) {
108
+ const pattern = /\[block\s+([\s\S]+?)\/\]/g;
109
+ const matches = [...content.matchAll(pattern)];
110
+ if (!matches.length) return content;
111
+
112
+ let output = content;
113
+ // Process in reverse so string indices stay valid as we replace
114
+ for (const match of matches.toReversed()) {
115
+ const attrsStr = match[1];
116
+ const attrs = {};
117
+ for (const [, key, dq, sq] of attrsStr.matchAll(/([\w-]+)=(?:"([^"]*)"|'([^']*)')/g)) {
118
+ attrs[key] = dq ?? sq ?? '';
119
+ }
120
+
121
+ const templateName = attrs.template;
122
+ if (!templateName) {
123
+ output = output.slice(0, match.index) + '' + output.slice(match.index + match[0].length);
124
+ continue;
125
+ }
126
+
127
+ let replacement = '';
128
+ try {
129
+ const tmpl = await loadBlockTemplate(templateName);
130
+ const rendered = tmpl.replace(/\{\{([\w_]+)\}\}/g, (_, key) => {
131
+ return escapeHtmlText(attrs[key] ?? '');
132
+ });
133
+ replacement = `<div class="dm-static-block">${rendered}</div>`;
134
+ } catch (_) {
135
+ // Template not found — emit nothing
136
+ }
137
+ output = output.slice(0, match.index) + replacement + output.slice(match.index + match[0].length);
138
+ }
139
+ return output;
140
+ }
141
+
99
142
  function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
100
143
  const columns = visibleFields.map(f => ({key: f.name, title: f.label || f.name}));
101
144
  const rows = entries.map(e => {
@@ -1023,6 +1066,108 @@ function processBadgeBlocks(markdown) {
1023
1066
  return restore(result);
1024
1067
  }
1025
1068
 
1069
+ /**
1070
+ * Pre-process [text] shortcodes before running through marked.
1071
+ *
1072
+ * Syntax (paired):
1073
+ * [text size="xl" bold italic color="primary" font="Georgia"]Your text[/text]
1074
+ *
1075
+ * Outputs a <span> with all resolved styles inlined.
1076
+ *
1077
+ * @param {string} markdown
1078
+ * @returns {string}
1079
+ */
1080
+ function processTextBlocks(markdown) {
1081
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
1082
+
1083
+ const SIZE_MAP = {
1084
+ xs: '.75rem', sm: '.875rem', base: '1rem', lg: '1.125rem',
1085
+ xl: '1.25rem', '2xl': '1.5rem', '3xl': '1.875rem', '4xl': '2.25rem',
1086
+ };
1087
+ const WEIGHT_MAP = {
1088
+ thin: '100', light: '300', normal: '400', medium: '500',
1089
+ semibold: '600', bold: '700', extrabold: '800', black: '900',
1090
+ };
1091
+ const SPACING_MAP = {
1092
+ tight: '-0.05em', normal: '0em', wide: '0.05em', wider: '0.1em',
1093
+ };
1094
+ const COLOR_TOKENS = {
1095
+ primary: 'var(--dm-color-primary)', secondary: 'var(--dm-color-secondary)',
1096
+ muted: 'var(--dm-text-muted)', danger: 'var(--dm-color-danger)',
1097
+ success: 'var(--dm-color-success)', warning: 'var(--dm-color-warning)',
1098
+ info: 'var(--dm-color-info)',
1099
+ };
1100
+ const FONT_MAP = {
1101
+ 'Georgia': 'Georgia,serif',
1102
+ 'Arial': 'Arial,sans-serif',
1103
+ 'Verdana': 'Verdana,sans-serif',
1104
+ 'Courier New': "'Courier New',monospace",
1105
+ 'Times New Roman': "'Times New Roman',serif",
1106
+ 'Trebuchet MS': "'Trebuchet MS',sans-serif",
1107
+ };
1108
+ const TRANSFORM_MAP = {
1109
+ upper: 'uppercase', lower: 'lowercase', capitalize: 'capitalize', none: 'none',
1110
+ };
1111
+ const DECORATION_MAP = {
1112
+ underline: 'underline', 'line-through': 'line-through', none: 'none',
1113
+ };
1114
+
1115
+ function buildText(attrStr, inner) {
1116
+ const attrs = parseShortcodeAttrs(attrStr || '');
1117
+ const styles = [];
1118
+
1119
+ if (attrs.size) {
1120
+ styles.push(`font-size:${SIZE_MAP[attrs.size] || attrs.size}`);
1121
+ }
1122
+
1123
+ // bold flag takes precedence over weight attr
1124
+ const isBold = /\bbold\b/i.test(attrStr);
1125
+ if (isBold) {
1126
+ styles.push('font-weight:700');
1127
+ } else if (attrs.weight && WEIGHT_MAP[attrs.weight]) {
1128
+ styles.push(`font-weight:${WEIGHT_MAP[attrs.weight]}`);
1129
+ }
1130
+
1131
+ if (/\bitalic\b/i.test(attrStr)) {
1132
+ styles.push('font-style:italic');
1133
+ }
1134
+
1135
+ if (attrs.color) {
1136
+ styles.push(`color:${COLOR_TOKENS[attrs.color] || attrs.color}`);
1137
+ }
1138
+
1139
+ if (attrs.font && FONT_MAP[attrs.font]) {
1140
+ styles.push(`font-family:${FONT_MAP[attrs.font]}`);
1141
+ }
1142
+
1143
+ if (attrs.transform && TRANSFORM_MAP[attrs.transform]) {
1144
+ styles.push(`text-transform:${TRANSFORM_MAP[attrs.transform]}`);
1145
+ }
1146
+
1147
+ if (attrs.decoration && DECORATION_MAP[attrs.decoration]) {
1148
+ styles.push(`text-decoration:${DECORATION_MAP[attrs.decoration]}`);
1149
+ }
1150
+
1151
+ if (attrs.spacing) {
1152
+ styles.push(`letter-spacing:${SPACING_MAP[attrs.spacing] || attrs.spacing}`);
1153
+ }
1154
+
1155
+ const styleAttr = styles.length ? ` style="${styles.join(';')}"` : '';
1156
+ const classAttr = attrs.class ? ` class="${escapeAttr(attrs.class)}"` : '';
1157
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
1158
+
1159
+ return `<span${styleAttr}${classAttr}${idAttr}>${inner.trim()}</span>`;
1160
+ }
1161
+
1162
+ // Note: [\s\S]*? allows multi-line body content (deliberate divergence from badge's [^\n]*?)
1163
+ const result = scrubbed.replace(
1164
+ /\[text([^\]]*)\]([\s\S]*?)\[\/text\]/gi,
1165
+ (_, attrStr, body) => buildText(attrStr, body)
1166
+ );
1167
+
1168
+ return restore(result);
1169
+ }
1170
+
1026
1171
  /**
1027
1172
  * Pre-process [button] shortcodes before running through marked.
1028
1173
  *
@@ -1369,7 +1514,7 @@ function processHeroBlocks(markdown) {
1369
1514
  (twinkleColour ? ` data-fx-colour="${escapeAttr(twinkleColour)}"` : '')
1370
1515
  : '';
1371
1516
 
1372
- const processedBody = processBadgeBlocks(processCardBlocks(processGridBlocks(restore(body.trim()))));
1517
+ const processedBody = processTextBlocks(processBadgeBlocks(processCardBlocks(processGridBlocks(restore(body.trim())))));
1373
1518
 
1374
1519
  let inner = '<div class="hero-content">';
1375
1520
  if (title) inner += `<h1 class="hero-title hero-title-responsive">${escapeAttr(title)}</h1>`;
@@ -1510,13 +1655,14 @@ export async function parseMarkdown(raw) {
1510
1655
  const extensions = getSanitizeExtensions();
1511
1656
 
1512
1657
  // Pipeline:
1513
- // beforeParse → collection → view → dconfig → plugin shortcodes → tabs → accordion → carousel
1514
- // → countdown → timeline → spacer → center → icon → form → hero → table → badge → button → link → cta
1515
- // → grid → card → slideover → marked → sanitize → afterParse
1658
+ // beforeParse → collection → view → staticBlock → dconfig → plugin shortcodes → tabs → accordion → carousel
1659
+ // → countdown → timeline → spacer → center → icon → form → hero → table → badge → button → link → cta
1660
+ // → grid → card → slideover → marked → sanitize → afterParse
1516
1661
  const preprocessed = applyTransforms('markdown:beforeParse', content);
1517
1662
  const withCollection = await processCollectionBlocks(preprocessed);
1518
1663
  const withView = await processViewBlocks(withCollection);
1519
- const withDconfig = processDConfigBlocks(withView);
1664
+ const withStaticBlock = await processStaticBlocks(withView);
1665
+ const withDconfig = processDConfigBlocks(withStaticBlock);
1520
1666
  const withPluginShortcodes = processPluginShortcodes(withDconfig);
1521
1667
  const withTabs = processTabsBlocks(withPluginShortcodes);
1522
1668
  const withAccordion = processAccordionBlocks(withTabs);
@@ -1529,9 +1675,10 @@ export async function parseMarkdown(raw) {
1529
1675
  const withForm = await processFormBlocks(withIcon);
1530
1676
  const withHero = processHeroBlocks(withForm);
1531
1677
  const withTable = processTableBlocks(withHero);
1532
- const withBadge = processBadgeBlocks(withTable);
1533
- const withButton = processButtonBlocks(withBadge);
1534
- const withLink = processLinkBlocks(withButton);
1678
+ const withBadge = processBadgeBlocks(withTable);
1679
+ const withText = processTextBlocks(withBadge);
1680
+ const withButton = processButtonBlocks(withText);
1681
+ const withLink = processLinkBlocks(withButton);
1535
1682
  const withCta = processCtaBlocks(withLink);
1536
1683
  const withGrid = processGridBlocks(withCta);
1537
1684
  const withCard = processCardBlocks(withGrid);
@@ -152,12 +152,13 @@ export async function registerPlugins(fastify) {
152
152
  const entryPath = path.join(PLUGINS_DIR, manifest.name, 'plugin.js');
153
153
  try {
154
154
  const { default: plugin } = await import(entryPath);
155
- await loadConfigDefaults(manifest.name);
155
+ const settings = await getPluginSettings(manifest.name);
156
156
  const prefix = `/api/plugins/${manifest.name}`;
157
157
  await fastify.register(plugin, {
158
158
  prefix,
159
159
  auth: {authenticate, requireRole, requireAdmin},
160
- hooks: {registerShortcode, registerSanitizeRules, registerTransform, on: hooks.on.bind(hooks)}
160
+ hooks: {registerShortcode, registerSanitizeRules, registerTransform, on: hooks.on.bind(hooks)},
161
+ settings
161
162
  });
162
163
  loaded.push(manifest.name);
163
164
  } catch (err) {
@@ -224,6 +225,36 @@ export async function getInjectionSnippets() {
224
225
  return {head, headLate, bodyEnd};
225
226
  }
226
227
 
228
+ /**
229
+ * Run a lifecycle hook (onEnable or onDisable) for a plugin if it exports one.
230
+ * Dynamically imports plugin.js and calls the named export with a context object.
231
+ * Errors are logged but do not crash the process.
232
+ *
233
+ * @param {string} name - Plugin directory name
234
+ * @param {string} hook - Export name to call ('onEnable' or 'onDisable')
235
+ * @param {import('fastify').FastifyInstance} fastify
236
+ * @returns {Promise<void>}
237
+ */
238
+ export async function runLifecycleHook(name, hook, fastify) {
239
+ // Validate hook name
240
+ if (!['onEnable', 'onDisable'].includes(hook)) return;
241
+
242
+ const pluginJsPath = path.join(PLUGINS_DIR, name, 'plugin.js');
243
+ try {
244
+ const mod = await import(pluginJsPath);
245
+ if (typeof mod[hook] !== 'function') return;
246
+
247
+ const [collections, roles] = await Promise.all([
248
+ import(path.resolve('server/services/collections.js')),
249
+ import(path.resolve('server/services/roles.js')),
250
+ ]);
251
+
252
+ await mod[hook]({ fastify, services: { collections, roles } });
253
+ } catch (err) {
254
+ fastify.log.error(`Plugin "${name}" lifecycle hook "${hook}" failed: ${err.message}`);
255
+ }
256
+ }
257
+
227
258
  /**
228
259
  * Return merged sidebar items, routes, and views from all enabled plugins.
229
260
  * Used by the frontend to dynamically extend the admin panel.
@@ -1,10 +0,0 @@
1
- <div class="view-header">
2
- <h1><span data-icon="chart-bar"></span> Analytics</h1>
3
- <button id="reset-btn" class="btn btn-ghost btn-sm">Reset stats</button>
4
- </div>
5
-
6
- <div class="card">
7
- <div class="card-body">
8
- <div id="analytics-table"></div>
9
- </div>
10
- </div>
@@ -1,51 +0,0 @@
1
- /**
2
- * Analytics Plugin — Admin View
3
- * Shows a sortable table of page hit counts.
4
- * Loaded dynamically from /plugins/ static path.
5
- */
6
- export const analyticsView = {
7
- templateUrl: '/plugins/example-analytics/admin/templates/analytics.html',
8
-
9
- async onMount($container) {
10
- await loadStats($container);
11
-
12
- $container.find('#reset-btn').on('click', async () => {
13
- const confirmed = await E.confirm('Reset all analytics data? This cannot be undone.');
14
- if (!confirmed) return;
15
- try {
16
- await fetch('/api/plugins/example-analytics/stats', {
17
- method: 'DELETE',
18
- headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}
19
- });
20
- E.toast('Analytics reset.', {type: 'success'});
21
- await loadStats($container);
22
- } catch {
23
- E.toast('Reset failed.', {type: 'error'});
24
- }
25
- });
26
-
27
- Domma.icons.scan();
28
- }
29
- };
30
-
31
- async function loadStats($container) {
32
- let stats = [];
33
- try {
34
- const res = await fetch('/api/plugins/example-analytics/stats', {
35
- headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}
36
- });
37
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
38
- stats = await res.json();
39
- } catch {
40
- E.toast('Could not load analytics data.', {type: 'error'});
41
- }
42
-
43
- T.create('#analytics-table', {
44
- data: stats,
45
- columns: [
46
- {key: 'url', title: 'Page URL', render: (val) => `<code>${val}</code>`},
47
- {key: 'hits', title: 'Page views'}
48
- ],
49
- emptyMessage: 'No page views recorded yet.'
50
- });
51
- }
@@ -1,6 +0,0 @@
1
- /**
2
- * Example Analytics Plugin — Default Configuration
3
- */
4
- export default {
5
- excludeAdmin: true
6
- };
@@ -1,58 +0,0 @@
1
- /**
2
- * Example Analytics Plugin — Server
3
- * Tracks page hits in a JSON file alongside the plugin.
4
- * Endpoints:
5
- * POST /api/plugins/example-analytics/hit - public: record a hit { url }
6
- * GET /api/plugins/example-analytics/stats - admin: return all hit counts
7
- * DELETE /api/plugins/example-analytics/stats - admin: reset all stats
8
- */
9
- import fs from 'fs/promises';
10
- import path from 'path';
11
- import {fileURLToPath} from 'url';
12
-
13
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
- const STATS_FILE = path.join(__dirname, 'stats.json');
15
-
16
- async function readStats() {
17
- try {
18
- return JSON.parse(await fs.readFile(STATS_FILE, 'utf8'));
19
- } catch {
20
- return {};
21
- }
22
- }
23
-
24
- async function writeStats(stats) {
25
- await fs.writeFile(STATS_FILE, JSON.stringify(stats, null, 2) + '\n', 'utf8');
26
- }
27
-
28
- export default async function analyticsPlugin(fastify, options) {
29
- const {authenticate, requireAdmin} = options.auth;
30
-
31
- // Record a page hit — called by the client-side injection script (public)
32
- fastify.post('/hit', async (request, reply) => {
33
- const {url} = request.body || {};
34
- if (!url || typeof url !== 'string') {
35
- return reply.status(400).send({error: 'url is required'});
36
- }
37
-
38
- const normalised = url.split('?')[0].replace(/\/$/, '') || '/';
39
- const stats = await readStats();
40
- stats[normalised] = (stats[normalised] || 0) + 1;
41
- await writeStats(stats);
42
- return {ok: true};
43
- });
44
-
45
- // Return all stats — admin only
46
- fastify.get('/stats', {preHandler: [authenticate, requireAdmin]}, async () => {
47
- const stats = await readStats();
48
- return Object.entries(stats)
49
- .map(([url, hits]) => ({url, hits}))
50
- .sort((a, b) => b.hits - a.hits);
51
- });
52
-
53
- // Reset stats — admin only
54
- fastify.delete('/stats', {preHandler: [authenticate, requireAdmin]}, async () => {
55
- await writeStats({});
56
- return {ok: true};
57
- });
58
- }
@@ -1,45 +0,0 @@
1
- {
2
- "name": "example-analytics",
3
- "displayName": "Analytics",
4
- "version": "1.0.0",
5
- "description": "Basic page view analytics. Tracks hits per page using a simple JSON store.",
6
- "author": "Darryl Waterhouse",
7
- "date": "2026-03-01",
8
- "icon": "chart-bar",
9
- "admin": {
10
- "sidebar": [
11
- {
12
- "id": "analytics",
13
- "text": "Analytics",
14
- "icon": "chart-bar",
15
- "url": "#/plugins/analytics",
16
- "section": "#/plugins/analytics"
17
- }
18
- ],
19
- "routes": [
20
- {
21
- "path": "/plugins/analytics",
22
- "view": "plugin-analytics",
23
- "title": "Analytics - Domma CMS"
24
- }
25
- ],
26
- "views": {
27
- "plugin-analytics": {
28
- "entry": "example-analytics/admin/views/analytics.js",
29
- "exportName": "analyticsView"
30
- }
31
- }
32
- },
33
- "inject": {
34
- "head": "public/inject-head.html",
35
- "bodyEnd": "public/inject-body.html"
36
- },
37
- "scaffold": {
38
- "reset": [
39
- {
40
- "path": "stats.json",
41
- "content": "{}"
42
- }
43
- ]
44
- }
45
- }
@@ -1,14 +0,0 @@
1
- <!-- example-analytics: page view tracker -->
2
- <script>
3
- (function () {
4
- var url = window.location.pathname;
5
- if (typeof fetch === 'function') {
6
- fetch('/api/plugins/example-analytics/hit', {
7
- method: 'POST',
8
- headers: {'Content-Type': 'application/json'},
9
- body: JSON.stringify({url: url})
10
- }).catch(function () { /* silent fail */
11
- });
12
- }
13
- })();
14
- </script>
@@ -1 +0,0 @@
1
- <!-- example-analytics: head injection (empty — tracking is done via body script) -->