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.
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +14 -12
- package/admin/js/views/collection-editor.js +5 -3
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/page-editor.js +27 -27
- package/config/plugins.json +20 -0
- package/config/site.json +1 -1
- package/package.json +2 -2
- package/plugins/analytics/stats.json +1 -1
- package/plugins/contacts/admin/templates/contacts.html +126 -0
- package/plugins/contacts/admin/views/contacts.js +710 -0
- package/plugins/contacts/config.js +6 -0
- package/plugins/contacts/data/contacts.json +20 -0
- package/plugins/contacts/plugin.js +351 -0
- package/plugins/contacts/plugin.json +23 -0
- package/plugins/docs/admin/templates/docs.html +69 -0
- package/plugins/docs/admin/views/docs.js +276 -0
- package/plugins/docs/config.js +8 -0
- package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +11 -0
- package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +11 -0
- package/plugins/docs/data/folders.json +9 -0
- package/plugins/docs/data/templates.json +1 -0
- package/plugins/docs/plugin.js +375 -0
- package/plugins/docs/plugin.json +23 -0
- package/plugins/job-board/admin/templates/application-detail.html +40 -0
- package/plugins/job-board/admin/templates/applications.html +10 -0
- package/plugins/job-board/admin/templates/companies.html +24 -0
- package/plugins/job-board/admin/templates/dashboard.html +36 -0
- package/plugins/job-board/admin/templates/job-editor.html +17 -0
- package/plugins/job-board/admin/templates/jobs.html +15 -0
- package/plugins/job-board/admin/templates/profile.html +17 -0
- package/plugins/job-board/admin/views/application-detail.js +62 -0
- package/plugins/job-board/admin/views/applications.js +47 -0
- package/plugins/job-board/admin/views/companies.js +104 -0
- package/plugins/job-board/admin/views/dashboard.js +88 -0
- package/plugins/job-board/admin/views/job-editor.js +86 -0
- package/plugins/job-board/admin/views/jobs.js +53 -0
- package/plugins/job-board/admin/views/profile.js +47 -0
- package/plugins/job-board/config.js +6 -0
- package/plugins/job-board/plugin.js +466 -0
- package/plugins/job-board/plugin.json +40 -0
- package/plugins/job-board/schemas/jb-agent-companies.json +17 -0
- package/plugins/job-board/schemas/jb-applications.json +20 -0
- package/plugins/job-board/schemas/jb-candidate-profiles.json +20 -0
- package/plugins/job-board/schemas/jb-companies.json +21 -0
- package/plugins/job-board/schemas/jb-jobs.json +23 -0
- package/plugins/notes/admin/templates/notes.html +92 -0
- package/plugins/notes/admin/views/notes.js +304 -0
- package/plugins/notes/config.js +6 -0
- package/plugins/notes/data/notes.json +1 -0
- package/plugins/notes/plugin.js +177 -0
- package/plugins/notes/plugin.json +23 -0
- package/plugins/todo/admin/templates/todo.html +164 -0
- package/plugins/todo/admin/views/todo.js +328 -0
- package/plugins/todo/config.js +7 -0
- package/plugins/todo/data/todos.json +1 -0
- package/plugins/todo/plugin.js +155 -0
- package/plugins/todo/plugin.json +23 -0
- package/server/routes/api/auth.js +2 -0
- package/server/routes/api/collections.js +59 -0
- package/server/routes/api/forms.js +3 -0
- package/server/routes/api/plugins.js +9 -1
- package/server/routes/api/settings.js +16 -1
- package/server/routes/public.js +2 -0
- package/server/services/markdown.js +155 -8
- package/server/services/plugins.js +33 -2
- package/plugins/example-analytics/admin/templates/analytics.html +0 -10
- package/plugins/example-analytics/admin/views/analytics.js +0 -51
- package/plugins/example-analytics/config.js +0 -6
- package/plugins/example-analytics/plugin.js +0 -58
- package/plugins/example-analytics/plugin.json +0 -45
- package/plugins/example-analytics/public/inject-body.html +0 -14
- package/plugins/example-analytics/public/inject-head.html +0 -1
- 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
|
|
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 {
|
package/server/routes/public.js
CHANGED
|
@@ -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
|
-
|
|
1515
|
-
|
|
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
|
|
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
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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) -->
|