domma-cms 0.1.0
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/LICENSE +21 -0
- package/README.md +469 -0
- package/admin/css/admin.css +1123 -0
- package/admin/index.html +72 -0
- package/admin/js/api.js +210 -0
- package/admin/js/app.js +270 -0
- package/admin/js/config/sidebar-config.js +107 -0
- package/admin/js/lib/card.js +63 -0
- package/admin/js/lib/image-editor.js +869 -0
- package/admin/js/lib/markdown-toolbar.js +421 -0
- package/admin/js/templates/dashboard.html +50 -0
- package/admin/js/templates/documentation.html +237 -0
- package/admin/js/templates/layouts.html +11 -0
- package/admin/js/templates/login.html +58 -0
- package/admin/js/templates/media.html +16 -0
- package/admin/js/templates/navigation.html +50 -0
- package/admin/js/templates/page-editor.html +126 -0
- package/admin/js/templates/pages.html +18 -0
- package/admin/js/templates/plugins.html +12 -0
- package/admin/js/templates/settings.html +190 -0
- package/admin/js/templates/tutorials.html +233 -0
- package/admin/js/templates/user-editor.html +12 -0
- package/admin/js/templates/users.html +10 -0
- package/admin/js/views/dashboard.js +48 -0
- package/admin/js/views/documentation.js +12 -0
- package/admin/js/views/index.js +33 -0
- package/admin/js/views/layouts.js +49 -0
- package/admin/js/views/login.js +254 -0
- package/admin/js/views/media.js +240 -0
- package/admin/js/views/navigation.js +152 -0
- package/admin/js/views/page-editor.js +479 -0
- package/admin/js/views/pages.js +64 -0
- package/admin/js/views/plugins.js +100 -0
- package/admin/js/views/settings.js +64 -0
- package/admin/js/views/tutorials.js +12 -0
- package/admin/js/views/user-editor.js +88 -0
- package/admin/js/views/users.js +73 -0
- package/bin/cli.js +334 -0
- package/config/auth.json +20 -0
- package/config/content.json +10 -0
- package/config/navigation.json +63 -0
- package/config/plugins.json +47 -0
- package/config/presets.json +34 -0
- package/config/server.json +6 -0
- package/config/site.json +33 -0
- package/package.json +67 -0
- package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
- package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
- package/plugins/back-to-top/config.js +10 -0
- package/plugins/back-to-top/plugin.js +24 -0
- package/plugins/back-to-top/plugin.json +36 -0
- package/plugins/back-to-top/public/inject-body.html +105 -0
- package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
- package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
- package/plugins/cookie-consent/config.js +30 -0
- package/plugins/cookie-consent/plugin.js +24 -0
- package/plugins/cookie-consent/plugin.json +36 -0
- package/plugins/cookie-consent/public/inject-body.html +69 -0
- package/plugins/custom-css/admin/templates/custom-css.html +17 -0
- package/plugins/custom-css/admin/views/custom-css.js +35 -0
- package/plugins/custom-css/config.js +1 -0
- package/plugins/custom-css/data/custom.css +0 -0
- package/plugins/custom-css/plugin.js +63 -0
- package/plugins/custom-css/plugin.json +32 -0
- package/plugins/custom-css/public/inject-head.html +1 -0
- package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
- package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
- package/plugins/domma-effects/config.js +9 -0
- package/plugins/domma-effects/plugin.js +22 -0
- package/plugins/domma-effects/plugin.json +36 -0
- package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
- package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
- package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
- package/plugins/domma-effects/public/celebrations/index.js +535 -0
- package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
- package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
- package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
- package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
- package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
- package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
- package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
- package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
- package/plugins/domma-effects/public/inject-body.html +268 -0
- package/plugins/example-analytics/admin/templates/analytics.html +10 -0
- package/plugins/example-analytics/admin/views/analytics.js +51 -0
- package/plugins/example-analytics/config.js +6 -0
- package/plugins/example-analytics/plugin.js +58 -0
- package/plugins/example-analytics/plugin.json +27 -0
- package/plugins/example-analytics/public/inject-body.html +13 -0
- package/plugins/example-analytics/public/inject-head.html +1 -0
- package/plugins/example-analytics/stats.json +1 -0
- package/plugins/form-builder/admin/templates/form-editor.html +158 -0
- package/plugins/form-builder/admin/templates/form-settings.html +29 -0
- package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
- package/plugins/form-builder/admin/templates/forms-list.html +17 -0
- package/plugins/form-builder/admin/views/form-editor.js +817 -0
- package/plugins/form-builder/admin/views/form-settings.js +38 -0
- package/plugins/form-builder/admin/views/form-submissions.js +295 -0
- package/plugins/form-builder/admin/views/forms-list.js +164 -0
- package/plugins/form-builder/config.js +9 -0
- package/plugins/form-builder/data/forms/contact-details.json +63 -0
- package/plugins/form-builder/data/forms/contact.json +52 -0
- package/plugins/form-builder/data/submissions/contact-details.json +1 -0
- package/plugins/form-builder/data/submissions/contact.json +14 -0
- package/plugins/form-builder/email.js +103 -0
- package/plugins/form-builder/plugin.js +454 -0
- package/plugins/form-builder/plugin.json +56 -0
- package/plugins/form-builder/public/inject-body.html +270 -0
- package/plugins/form-builder/public/inject-head.html +42 -0
- package/public/css/site.css +189 -0
- package/public/js/site.js +109 -0
- package/scripts/copy-domma.js +48 -0
- package/scripts/fresh.js +41 -0
- package/scripts/reset.js +124 -0
- package/scripts/seed.js +666 -0
- package/scripts/setup.js +263 -0
- package/server/config.js +56 -0
- package/server/middleware/auth.js +97 -0
- package/server/routes/api/auth.js +116 -0
- package/server/routes/api/layouts.js +25 -0
- package/server/routes/api/media.js +93 -0
- package/server/routes/api/navigation.js +37 -0
- package/server/routes/api/pages.js +118 -0
- package/server/routes/api/plugins.js +46 -0
- package/server/routes/api/settings.js +25 -0
- package/server/routes/api/users.js +110 -0
- package/server/routes/public.js +108 -0
- package/server/server.js +169 -0
- package/server/services/content.js +298 -0
- package/server/services/images.js +334 -0
- package/server/services/markdown.js +297 -0
- package/server/services/plugins.js +246 -0
- package/server/services/renderer.js +80 -0
- package/server/services/users.js +212 -0
- package/server/templates/page.html +78 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown Service
|
|
3
|
+
* Parses frontmatter with gray-matter and renders Markdown to HTML with marked.
|
|
4
|
+
*/
|
|
5
|
+
import matter from 'gray-matter';
|
|
6
|
+
import {marked} from 'marked';
|
|
7
|
+
import sanitizeHtml from 'sanitize-html';
|
|
8
|
+
|
|
9
|
+
// Configure marked for safe output
|
|
10
|
+
marked.setOptions({
|
|
11
|
+
gfm: true,
|
|
12
|
+
breaks: false
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse key="value" or key='value' pairs from a shortcode attribute string.
|
|
17
|
+
* @param {string} attrStr
|
|
18
|
+
* @returns {object}
|
|
19
|
+
*/
|
|
20
|
+
function parseShortcodeAttrs(attrStr) {
|
|
21
|
+
const attrs = {};
|
|
22
|
+
for (const [, key, val] of attrStr.matchAll(/(\w+)=["']([^"']*)["']/g)) {
|
|
23
|
+
attrs[key] = val;
|
|
24
|
+
}
|
|
25
|
+
return attrs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pre-process Domma Effects shortcodes before running through marked.
|
|
30
|
+
*
|
|
31
|
+
* Wrapping shortcodes output <div class="dm-fx-{name}" data-fx-*="...">...</div>.
|
|
32
|
+
* Self-closing [counter /] outputs a <span class="dm-fx-counter"> with formatted text.
|
|
33
|
+
* CSS-only shortcodes ([animate], [ambient]) apply utility classes with no JS needed.
|
|
34
|
+
*
|
|
35
|
+
* Without the domma-effects plugin enabled, content is visible but static.
|
|
36
|
+
* With the plugin, inject-body.html scans dm-fx-* classes and calls Domma.effects.*.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} markdown
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function processEffectShortcodes(markdown) {
|
|
42
|
+
let result = markdown;
|
|
43
|
+
|
|
44
|
+
// Process inline/self-closing shortcodes FIRST so they survive marked.parse()
|
|
45
|
+
// inside wrapping shortcode bodies.
|
|
46
|
+
|
|
47
|
+
// Self-closing: [counter to="100" from="0" prefix="$" suffix="+" duration="2000" /]
|
|
48
|
+
result = result.replace(
|
|
49
|
+
/\[counter([^\]]*?)\/\]/gi,
|
|
50
|
+
(_, attrStr) => {
|
|
51
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
52
|
+
const to = attrs.to || '0';
|
|
53
|
+
const prefix = attrs.prefix || '';
|
|
54
|
+
const suffix = attrs.suffix || '';
|
|
55
|
+
const dataAttrs = Object.entries(attrs)
|
|
56
|
+
.map(([k, v]) => ` data-fx-${k}="${v}"`)
|
|
57
|
+
.join('');
|
|
58
|
+
return `<span class="dm-fx-counter"${dataAttrs}>${prefix}${to}${suffix}</span>`;
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Self-closing: [firework type="burst" colour="rainbow" size="lg" continuous="true" /]
|
|
63
|
+
// (?!s) prevents matching [fireworks (plural) which is handled separately below.
|
|
64
|
+
result = result.replace(
|
|
65
|
+
/\[firework(?!s)([^\]]*?)\/\]/gi,
|
|
66
|
+
(_, attrStr) => {
|
|
67
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
68
|
+
const classes = ['firework'];
|
|
69
|
+
if (attrs.type) classes.push(`firework-${attrs.type}`);
|
|
70
|
+
if (attrs.colour) classes.push(`firework-${attrs.colour}`);
|
|
71
|
+
if (attrs.size) classes.push(`firework-${attrs.size}`);
|
|
72
|
+
if (attrs.continuous === 'true') classes.push('firework-continuous');
|
|
73
|
+
if (attrs.hover === 'true') classes.push('firework-on-hover');
|
|
74
|
+
return `<div class="${classes.join(' ')}"></div>`;
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Self-closing: [celebrate theme="christmas" intensity="medium" /]
|
|
79
|
+
result = result.replace(
|
|
80
|
+
/\[celebrate([^\]]*?)\/\]/gi,
|
|
81
|
+
(_, attrStr) => {
|
|
82
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
83
|
+
const dataAttrs = Object.entries(attrs)
|
|
84
|
+
.map(([k, v]) => ` data-fx-${k}="${v}"`)
|
|
85
|
+
.join('');
|
|
86
|
+
return `<div class="dm-fx-celebrate"${dataAttrs}></div>`;
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Wrapping container: [fireworks]...[/fireworks]
|
|
91
|
+
// Body is already-processed HTML (firework self-closing ran above) — passed raw, no marked.parse.
|
|
92
|
+
result = result.replace(
|
|
93
|
+
/\[fireworks([^\]]*)\]([\s\S]*?)\[\/fireworks\]/gi,
|
|
94
|
+
(_, _attrStr, body) => `<div class="fireworks-container">${body.trim()}</div>\n`
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Wrapping: [firework type="sparkle" colour="info"]Click me[/firework]
|
|
98
|
+
result = result.replace(
|
|
99
|
+
/\[firework(?!s)([^\]]*)\]([\s\S]*?)\[\/firework\]/gi,
|
|
100
|
+
(_, attrStr, body) => {
|
|
101
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
102
|
+
const classes = ['firework'];
|
|
103
|
+
if (attrs.type) classes.push(`firework-${attrs.type}`);
|
|
104
|
+
if (attrs.colour) classes.push(`firework-${attrs.colour}`);
|
|
105
|
+
if (attrs.size) classes.push(`firework-${attrs.size}`);
|
|
106
|
+
if (attrs.continuous === 'true') classes.push('firework-continuous');
|
|
107
|
+
if (attrs.hover === 'true') classes.push('firework-on-hover');
|
|
108
|
+
const innerHtml = marked.parse(processCardBlocks(processGridBlocks(body.trim())));
|
|
109
|
+
return `<div class="${classes.join(' ')}">${innerHtml}</div>\n`;
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Wrapping effects: [name attr="val"]...[/name]
|
|
114
|
+
// Counter spans and firework divs are already HTML at this point so marked.parse() preserves them.
|
|
115
|
+
const wrappingEffects = ['reveal', 'breathe', 'pulse', 'shake', 'scribe', 'scramble', 'ripple', 'twinkle'];
|
|
116
|
+
|
|
117
|
+
for (const name of wrappingEffects) {
|
|
118
|
+
result = result.replace(
|
|
119
|
+
new RegExp(`\\[${name}([^\\]]*)\\]([\\s\\S]*?)\\[\\/${name}\\]`, 'gi'),
|
|
120
|
+
(_, attrStr, body) => {
|
|
121
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
122
|
+
const dataAttrs = Object.entries(attrs)
|
|
123
|
+
.map(([k, v]) => ` data-fx-${k}="${v}"`)
|
|
124
|
+
.join('');
|
|
125
|
+
const innerHtml = marked.parse(processCardBlocks(processGridBlocks(body.trim())));
|
|
126
|
+
return `<div class="dm-fx-${name}"${dataAttrs}>${innerHtml}</div>\n`;
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// CSS-only: [animate type="fade-in-up" duration="slow"]...[/animate]
|
|
132
|
+
result = result.replace(
|
|
133
|
+
/\[animate([^\]]*)\]([\s\S]*?)\[\/animate\]/gi,
|
|
134
|
+
(_, attrStr, body) => {
|
|
135
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
136
|
+
const classes = [];
|
|
137
|
+
if (attrs.type) classes.push(`animate-${attrs.type}`);
|
|
138
|
+
if (attrs.duration) classes.push(`animate-duration-${attrs.duration}`);
|
|
139
|
+
if (attrs.delay) classes.push(`animate-delay-${attrs.delay}`);
|
|
140
|
+
if (attrs.repeat) classes.push(`animate-${attrs.repeat}`);
|
|
141
|
+
return `<div class="${classes.join(' ')}">${marked.parse(processCardBlocks(processGridBlocks(body.trim())))}</div>\n`;
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// CSS-only: [ambient type="float-blobs" speed="slow" intensity="subtle"]...[/ambient]
|
|
146
|
+
result = result.replace(
|
|
147
|
+
/\[ambient([^\]]*)\]([\s\S]*?)\[\/ambient\]/gi,
|
|
148
|
+
(_, attrStr, body) => {
|
|
149
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
150
|
+
const classes = [];
|
|
151
|
+
if (attrs.type) classes.push(`bg-ambient-${attrs.type}`);
|
|
152
|
+
if (attrs.speed) classes.push(`bg-ambient-${attrs.speed}`);
|
|
153
|
+
if (attrs.intensity) classes.push(`bg-ambient-${attrs.intensity}`);
|
|
154
|
+
return `<div class="${classes.join(' ')}">${marked.parse(processCardBlocks(processGridBlocks(body.trim())))}</div>\n`;
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pre-process [row], [grid], and [col] shortcodes before running through marked.
|
|
163
|
+
*
|
|
164
|
+
* Uses Domma's native CSS Grid system — NOT the .col compatibility layer.
|
|
165
|
+
*
|
|
166
|
+
* Supported shortcodes:
|
|
167
|
+
*
|
|
168
|
+
* [grid cols="N" gap="N"]
|
|
169
|
+
* [col span="N"] Content [/col]
|
|
170
|
+
* [/grid]
|
|
171
|
+
* → <div class="grid grid-cols-N gap-N"><div class="col-span-N">…</div></div>
|
|
172
|
+
*
|
|
173
|
+
* [row gap="N"]
|
|
174
|
+
* [col] Content [/col]
|
|
175
|
+
* [/row]
|
|
176
|
+
* → <div class="row gap-N"><div>…</div></div>
|
|
177
|
+
*
|
|
178
|
+
* @param {string} markdown
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
function processGridBlocks(markdown) {
|
|
182
|
+
// Pass 1: [col span="N"]...[/col] → <div class="col-span-N">...</div>
|
|
183
|
+
let result = markdown.replace(
|
|
184
|
+
/\[col([^\]]*)\]([\s\S]*?)\[\/col\]/gi,
|
|
185
|
+
(_, attrStr, body) => {
|
|
186
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
187
|
+
const cls = attrs.span ? ` class="col-span-${attrs.span}"` : '';
|
|
188
|
+
return `<div${cls}>${marked.parse(processCardBlocks(body.trim()))}</div>`;
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Pass 2: [row gap="N" class="extra"]...[/row] → <div class="row ...">...</div>
|
|
193
|
+
result = result.replace(
|
|
194
|
+
/\[row([^\]]*)\]([\s\S]*?)\[\/row\]/gi,
|
|
195
|
+
(_, attrStr, inner) => {
|
|
196
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
197
|
+
const classes = ['row'];
|
|
198
|
+
if (attrs.gap) classes.push(`gap-${attrs.gap}`);
|
|
199
|
+
if (attrs.class) classes.push(attrs.class);
|
|
200
|
+
return `<div class="${classes.join(' ')}">${inner}</div>\n`;
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Pass 3: [grid cols="N" gap="N"]...[/grid] → <div class="grid grid-cols-N ...">...</div>
|
|
205
|
+
result = result.replace(
|
|
206
|
+
/\[grid([^\]]*)\]([\s\S]*?)\[\/grid\]/gi,
|
|
207
|
+
(_, attrStr, inner) => {
|
|
208
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
209
|
+
const classes = ['grid'];
|
|
210
|
+
if (attrs.cols) classes.push(`grid-cols-${attrs.cols}`);
|
|
211
|
+
if (attrs.gap) classes.push(`gap-${attrs.gap}`);
|
|
212
|
+
if (attrs.class) classes.push(attrs.class);
|
|
213
|
+
return `<div class="${classes.join(' ')}">${inner}</div>\n`;
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Pre-process [card] shortcodes before running through marked.
|
|
222
|
+
*
|
|
223
|
+
* Syntax:
|
|
224
|
+
* [card title="Optional Title" collapsible="true"]
|
|
225
|
+
* Body content (supports Markdown).
|
|
226
|
+
* [/card]
|
|
227
|
+
*
|
|
228
|
+
* Supported attributes:
|
|
229
|
+
* title - Card header title (omit for no header)
|
|
230
|
+
* collapsible - "true" to make the card body toggle on header click
|
|
231
|
+
*
|
|
232
|
+
* @param {string} markdown
|
|
233
|
+
* @returns {string}
|
|
234
|
+
*/
|
|
235
|
+
function processCardBlocks(markdown) {
|
|
236
|
+
return markdown.replace(
|
|
237
|
+
/\[card([^\]]*)\]([\s\S]*?)\[\/card\]/gi,
|
|
238
|
+
(_, attrStr, body) => {
|
|
239
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
240
|
+
const title = attrs.title?.trim() || '';
|
|
241
|
+
const collapsible = attrs.collapsible === 'true';
|
|
242
|
+
|
|
243
|
+
const titleHtml = title
|
|
244
|
+
? `<div class="card-header"><h2>${title}</h2></div>`
|
|
245
|
+
: '';
|
|
246
|
+
const bodyHtml = marked.parse(body.trim());
|
|
247
|
+
const extra = collapsible ? ' data-collapsible="true"' : '';
|
|
248
|
+
return `<div class="card mb-4"${extra}>${titleHtml}<div class="card-body">${bodyHtml}</div></div>\n`;
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parse a Markdown file string into frontmatter data and rendered HTML.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} raw - Raw file content (frontmatter + Markdown body)
|
|
257
|
+
* @returns {{ data: object, content: string, html: string }}
|
|
258
|
+
*/
|
|
259
|
+
export function parseMarkdown(raw) {
|
|
260
|
+
const { data, content } = matter(raw);
|
|
261
|
+
const html = sanitizeHtml(marked.parse(processCardBlocks(processGridBlocks(processEffectShortcodes(content)))), {
|
|
262
|
+
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
|
263
|
+
'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
264
|
+
'form', 'input', 'textarea', 'select', 'option', 'optgroup',
|
|
265
|
+
'button', 'label', 'fieldset', 'legend'
|
|
266
|
+
]),
|
|
267
|
+
allowedAttributes: {
|
|
268
|
+
...sanitizeHtml.defaults.allowedAttributes,
|
|
269
|
+
'*': ['class', 'id', 'style', 'data-*'],
|
|
270
|
+
img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
|
|
271
|
+
form: ['action', 'method'],
|
|
272
|
+
input: ['type', 'name', 'placeholder', 'value', 'required', 'disabled',
|
|
273
|
+
'readonly', 'min', 'max', 'step', 'pattern', 'maxlength',
|
|
274
|
+
'minlength', 'checked', 'autocomplete'],
|
|
275
|
+
textarea: ['name', 'placeholder', 'rows', 'cols', 'required',
|
|
276
|
+
'disabled', 'readonly', 'maxlength'],
|
|
277
|
+
select: ['name', 'required', 'disabled', 'multiple'],
|
|
278
|
+
option: ['value', 'selected', 'disabled'],
|
|
279
|
+
optgroup: ['label', 'disabled'],
|
|
280
|
+
button: ['type', 'disabled'],
|
|
281
|
+
label: ['for'],
|
|
282
|
+
fieldset: ['disabled']
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
return { data, content, html };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Serialise a page object back to a Markdown file string.
|
|
290
|
+
*
|
|
291
|
+
* @param {object} frontmatter - Page metadata fields
|
|
292
|
+
* @param {string} body - Markdown body
|
|
293
|
+
* @returns {string}
|
|
294
|
+
*/
|
|
295
|
+
export function serialiseMarkdown(frontmatter, body) {
|
|
296
|
+
return matter.stringify(body || '', frontmatter);
|
|
297
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Service
|
|
3
|
+
* Discovers, loads, and manages CMS plugins.
|
|
4
|
+
*
|
|
5
|
+
* Plugin structure:
|
|
6
|
+
* plugins/{name}/plugin.json — manifest (required)
|
|
7
|
+
* plugins/{name}/plugin.js — Fastify plugin, default export (required)
|
|
8
|
+
* plugins/{name}/config.js — default settings, default export (required)
|
|
9
|
+
* plugins/{name}/admin/ — optional admin views/templates
|
|
10
|
+
* plugins/{name}/public/ — optional injection HTML snippets
|
|
11
|
+
* plugins/{name}/data/ — optional plugin-specific data store
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'fs/promises';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import {getConfig, saveConfig} from '../config.js';
|
|
16
|
+
import {authenticate, requireAdmin, requireRole} from '../middleware/auth.js';
|
|
17
|
+
|
|
18
|
+
const PLUGINS_DIR = path.resolve('plugins');
|
|
19
|
+
|
|
20
|
+
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'version', 'description', 'author', 'date', 'icon'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Scan the plugins/ directory and return all valid manifests.
|
|
24
|
+
* Validates mandatory fields and required files (plugin.js, config.js).
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<object[]>} Array of validated plugin manifests
|
|
27
|
+
*/
|
|
28
|
+
export async function discoverPlugins() {
|
|
29
|
+
await fs.mkdir(PLUGINS_DIR, { recursive: true });
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = await fs.readdir(PLUGINS_DIR, { withFileTypes: true });
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const plugins = [];
|
|
38
|
+
for (const entry of entries.filter(e => e.isDirectory())) {
|
|
39
|
+
const dir = entry.name;
|
|
40
|
+
const pluginDir = path.join(PLUGINS_DIR, dir);
|
|
41
|
+
const manifestPath = path.join(pluginDir, 'plugin.json');
|
|
42
|
+
|
|
43
|
+
let manifest;
|
|
44
|
+
try {
|
|
45
|
+
const raw = await fs.readFile(manifestPath, 'utf8');
|
|
46
|
+
manifest = JSON.parse(raw);
|
|
47
|
+
} catch {
|
|
48
|
+
// Skip directories without a valid manifest
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Validate mandatory fields
|
|
53
|
+
let valid = true;
|
|
54
|
+
for (const field of REQUIRED_MANIFEST_FIELDS) {
|
|
55
|
+
if (!manifest[field]) {
|
|
56
|
+
console.warn(`Plugin "${dir}": missing required field "${field}" in plugin.json — skipping`);
|
|
57
|
+
valid = false;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!valid) continue;
|
|
62
|
+
|
|
63
|
+
// Validate required files
|
|
64
|
+
const pluginJsPath = path.join(pluginDir, 'plugin.js');
|
|
65
|
+
const configJsPath = path.join(pluginDir, 'config.js');
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await fs.access(pluginJsPath);
|
|
69
|
+
} catch {
|
|
70
|
+
console.warn(`Plugin "${dir}": missing required file plugin.js — skipping`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await fs.access(configJsPath);
|
|
76
|
+
} catch {
|
|
77
|
+
console.warn(`Plugin "${dir}": missing required file config.js — skipping`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
plugins.push({...manifest, name: dir});
|
|
82
|
+
}
|
|
83
|
+
return plugins;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const _configDefaultsCache = new Map();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Load and cache config.js defaults for a single plugin.
|
|
90
|
+
* Only called for enabled plugins at registration time.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} name - Plugin directory name
|
|
93
|
+
* @returns {Promise<object>} Defaults object from config.js
|
|
94
|
+
*/
|
|
95
|
+
async function loadConfigDefaults(name) {
|
|
96
|
+
if (_configDefaultsCache.has(name)) return _configDefaultsCache.get(name);
|
|
97
|
+
const configJsPath = path.join(PLUGINS_DIR, name, 'config.js');
|
|
98
|
+
let defaults = {};
|
|
99
|
+
try {
|
|
100
|
+
const {default: exported} = await import(configJsPath);
|
|
101
|
+
defaults = exported || {};
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.warn(`Plugin "${name}": failed to load config.js — ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
_configDefaultsCache.set(name, defaults);
|
|
106
|
+
return defaults;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read the enabled/disabled state for all plugins from config/plugins.json.
|
|
111
|
+
*
|
|
112
|
+
* @returns {object} Map of plugin name → { enabled, settings }
|
|
113
|
+
*/
|
|
114
|
+
export function getPluginStates() {
|
|
115
|
+
try {
|
|
116
|
+
return getConfig('plugins');
|
|
117
|
+
} catch {
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Persist enabled/disabled state and settings for a single plugin.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} name
|
|
126
|
+
* @param {object} state - { enabled, settings }
|
|
127
|
+
* @returns {void}
|
|
128
|
+
*/
|
|
129
|
+
export function savePluginState(name, state) {
|
|
130
|
+
const states = getPluginStates();
|
|
131
|
+
states[name] = { ...states[name], ...state };
|
|
132
|
+
saveConfig('plugins', states);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Register server-side Fastify plugins for all enabled plugins.
|
|
137
|
+
* Always loads plugin.js as the entry point.
|
|
138
|
+
*
|
|
139
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
140
|
+
* @returns {Promise<void>}
|
|
141
|
+
*/
|
|
142
|
+
export async function registerPlugins(fastify) {
|
|
143
|
+
const manifests = await discoverPlugins();
|
|
144
|
+
const states = getPluginStates();
|
|
145
|
+
|
|
146
|
+
const loaded = [];
|
|
147
|
+
for (const manifest of manifests) {
|
|
148
|
+
const state = states[manifest.name] || {};
|
|
149
|
+
if (!state.enabled) continue;
|
|
150
|
+
|
|
151
|
+
const entryPath = path.join(PLUGINS_DIR, manifest.name, 'plugin.js');
|
|
152
|
+
try {
|
|
153
|
+
const { default: plugin } = await import(entryPath);
|
|
154
|
+
await loadConfigDefaults(manifest.name);
|
|
155
|
+
const prefix = `/api/plugins/${manifest.name}`;
|
|
156
|
+
await fastify.register(plugin, {prefix, auth: {authenticate, requireRole, requireAdmin}});
|
|
157
|
+
loaded.push(manifest.name);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
fastify.log.error(`Plugin "${manifest.name}" server failed to load: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (loaded.length) {
|
|
164
|
+
fastify.log.info(`[plugins] Loaded ${loaded.length} plugin${loaded.length === 1 ? '' : 's'}: ${loaded.join(', ')}`);
|
|
165
|
+
} else {
|
|
166
|
+
fastify.log.info('[plugins] No plugins enabled.');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Return merged settings for a plugin: config.js defaults + user overrides from config/plugins.json.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} name - Plugin name
|
|
174
|
+
* @returns {Promise<object>} Merged settings object
|
|
175
|
+
*/
|
|
176
|
+
export async function getPluginSettings(name) {
|
|
177
|
+
const defaults = await loadConfigDefaults(name);
|
|
178
|
+
const states = getPluginStates();
|
|
179
|
+
const userOverrides = states[name]?.settings || {};
|
|
180
|
+
return {...defaults, ...userOverrides};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Read and concatenate HTML injection snippets from all enabled plugins.
|
|
185
|
+
*
|
|
186
|
+
* @returns {Promise<{ head: string, bodyEnd: string }>}
|
|
187
|
+
*/
|
|
188
|
+
export async function getInjectionSnippets() {
|
|
189
|
+
const manifests = await discoverPlugins();
|
|
190
|
+
const states = getPluginStates();
|
|
191
|
+
|
|
192
|
+
let head = '';
|
|
193
|
+
let headLate = '';
|
|
194
|
+
let bodyEnd = '';
|
|
195
|
+
|
|
196
|
+
for (const manifest of manifests) {
|
|
197
|
+
const state = states[manifest.name] || {};
|
|
198
|
+
if (!state.enabled || !manifest.inject) continue;
|
|
199
|
+
|
|
200
|
+
const pluginRoot = path.resolve(PLUGINS_DIR, manifest.name);
|
|
201
|
+
|
|
202
|
+
for (const [key, target] of [['head', 'head'], ['headLate', 'headLate'], ['bodyEnd', 'bodyEnd']]) {
|
|
203
|
+
if (!manifest.inject[key]) continue;
|
|
204
|
+
const filePath = path.resolve(PLUGINS_DIR, manifest.name, manifest.inject[key]);
|
|
205
|
+
if (!filePath.startsWith(pluginRoot + path.sep)) {
|
|
206
|
+
console.warn(`Plugin "${manifest.name}": inject.${key} path escapes plugin directory — skipping`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
211
|
+
if (target === 'head') head += content + '\n';
|
|
212
|
+
if (target === 'headLate') headLate += content + '\n';
|
|
213
|
+
if (target === 'bodyEnd') bodyEnd += content + '\n';
|
|
214
|
+
} catch { /* snippet not found — skip */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {head, headLate, bodyEnd};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Return merged sidebar items, routes, and views from all enabled plugins.
|
|
224
|
+
* Used by the frontend to dynamically extend the admin panel.
|
|
225
|
+
*
|
|
226
|
+
* @returns {Promise<{ sidebar: object[], routes: object[], views: object }>}
|
|
227
|
+
*/
|
|
228
|
+
export async function getAdminPluginConfig() {
|
|
229
|
+
const manifests = await discoverPlugins();
|
|
230
|
+
const states = getPluginStates();
|
|
231
|
+
|
|
232
|
+
const sidebar = [];
|
|
233
|
+
const routes = [];
|
|
234
|
+
const views = {};
|
|
235
|
+
|
|
236
|
+
for (const manifest of manifests) {
|
|
237
|
+
const state = states[manifest.name] || {};
|
|
238
|
+
if (!state.enabled || !manifest.admin) continue;
|
|
239
|
+
|
|
240
|
+
if (manifest.admin.sidebar) sidebar.push(...manifest.admin.sidebar);
|
|
241
|
+
if (manifest.admin.routes) routes.push(...manifest.admin.routes);
|
|
242
|
+
if (manifest.admin.views) Object.assign(views, manifest.admin.views);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { sidebar, routes, views };
|
|
246
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer Service
|
|
3
|
+
* Assembles the full HTML page shell from the template + page data + site config.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import {fileURLToPath} from 'url';
|
|
8
|
+
import {getConfig} from '../config.js';
|
|
9
|
+
import {getInjectionSnippets} from './plugins.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const TEMPLATE_PATH = path.join(__dirname, '../templates/page.html');
|
|
13
|
+
|
|
14
|
+
async function getTemplate() {
|
|
15
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
16
|
+
return _templateCache || (_templateCache = await fs.readFile(TEMPLATE_PATH, 'utf8'));
|
|
17
|
+
}
|
|
18
|
+
return fs.readFile(TEMPLATE_PATH, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
let _templateCache = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render a page to a full HTML string.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} page - Parsed page from content service
|
|
26
|
+
* @returns {Promise<string>}
|
|
27
|
+
*/
|
|
28
|
+
export async function renderPage(page) {
|
|
29
|
+
const [template, site, navigation, presets, injection] = await Promise.all([
|
|
30
|
+
getTemplate(),
|
|
31
|
+
Promise.resolve(getConfig('site')),
|
|
32
|
+
Promise.resolve(getConfig('navigation')),
|
|
33
|
+
Promise.resolve(getConfig('presets')),
|
|
34
|
+
getInjectionSnippets()
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const preset = presets[page.layout] || presets['default'] || {};
|
|
38
|
+
|
|
39
|
+
const seoTitle = page.seo?.title || `${page.title}${site.seo?.titleSeparator || ' | '}${site.seo?.defaultTitle || site.title}`;
|
|
40
|
+
const seoDescription = page.seo?.description || site.seo?.defaultDescription || '';
|
|
41
|
+
const ogImage = page.seo?.image || site.seo?.defaultImage || '';
|
|
42
|
+
|
|
43
|
+
const vars = {
|
|
44
|
+
seoTitle,
|
|
45
|
+
seoDescription,
|
|
46
|
+
ogImage,
|
|
47
|
+
title: page.title,
|
|
48
|
+
html: page.html,
|
|
49
|
+
theme: site.theme || 'charcoal-dark',
|
|
50
|
+
layout: page.layout || 'default',
|
|
51
|
+
showNavbar: preset.navbar !== false,
|
|
52
|
+
showFooter: preset.footer !== false,
|
|
53
|
+
showSidebar: preset.sidebar === true || page.sidebar === true,
|
|
54
|
+
navJson: JSON.stringify(navigation),
|
|
55
|
+
siteJson: JSON.stringify({ title: site.title, footer: site.footer }),
|
|
56
|
+
headInject: injection.head,
|
|
57
|
+
headInjectLate: injection.headLate,
|
|
58
|
+
bodyEndInject: injection.bodyEnd
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return interpolate(template, vars);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Simple template interpolation.
|
|
66
|
+
* Handles {{variable}}, {{#if flag}}...{{/if}}.
|
|
67
|
+
*/
|
|
68
|
+
function interpolate(template, vars) {
|
|
69
|
+
let html = template.replace(/\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (_, key, inner) => {
|
|
70
|
+
return vars[key] ? inner : '';
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
html = html.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
74
|
+
const val = vars[key];
|
|
75
|
+
if (val === undefined || val === null) return '';
|
|
76
|
+
return String(val);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return html;
|
|
80
|
+
}
|