domma-cms 0.1.0 → 0.2.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/admin/css/admin.css +78 -1
- package/admin/js/api.js +32 -0
- package/admin/js/app.js +24 -7
- package/admin/js/config/sidebar-config.js +8 -0
- package/admin/js/templates/collection-editor.html +80 -0
- package/admin/js/templates/collection-entries.html +36 -0
- package/admin/js/templates/collections.html +12 -0
- package/admin/js/templates/documentation.html +136 -0
- package/admin/js/templates/navigation.html +26 -4
- package/admin/js/templates/page-editor.html +91 -85
- package/admin/js/templates/settings.html +433 -172
- package/admin/js/views/collection-editor.js +487 -0
- package/admin/js/views/collection-entries.js +484 -0
- package/admin/js/views/collections.js +153 -0
- package/admin/js/views/dashboard.js +14 -6
- package/admin/js/views/index.js +9 -3
- package/admin/js/views/login.js +3 -2
- package/admin/js/views/navigation.js +77 -11
- package/admin/js/views/page-editor.js +207 -25
- package/admin/js/views/pages.js +14 -6
- package/admin/js/views/settings.js +137 -2
- package/admin/js/views/users.js +10 -7
- package/bin/cli.js +37 -10
- package/config/auth.json +2 -1
- package/config/content.json +1 -0
- package/config/navigation.json +14 -4
- package/config/plugins.json +0 -18
- package/config/presets.json +4 -8
- package/config/site.json +44 -3
- package/package.json +6 -2
- package/plugins/domma-effects/admin/templates/domma-effects.html +92 -3
- package/plugins/domma-effects/plugin.js +125 -0
- package/plugins/domma-effects/public/inject-body.html +19 -0
- package/plugins/example-analytics/admin/views/analytics.js +2 -2
- package/plugins/example-analytics/plugin.json +8 -0
- package/plugins/example-analytics/stats.json +15 -1
- package/plugins/form-builder/admin/templates/form-editor.html +19 -6
- package/plugins/form-builder/admin/views/form-editor.js +634 -9
- package/plugins/form-builder/admin/views/form-submissions.js +4 -4
- package/plugins/form-builder/admin/views/forms-list.js +5 -5
- package/plugins/form-builder/data/forms/consent.json +104 -0
- package/plugins/form-builder/data/forms/contacts.json +66 -0
- package/plugins/form-builder/data/submissions/consent.json +13 -0
- package/plugins/form-builder/data/submissions/contacts.json +26 -0
- package/plugins/form-builder/plugin.js +62 -11
- package/plugins/form-builder/plugin.json +12 -16
- package/plugins/form-builder/public/form-logic-engine.js +568 -0
- package/plugins/form-builder/public/inject-body.html +88 -6
- package/plugins/form-builder/public/inject-head.html +16 -0
- package/plugins/form-builder/public/package.json +1 -0
- package/public/css/site.css +113 -0
- package/public/js/btt.js +90 -0
- package/public/js/cookie-consent.js +61 -0
- package/public/js/site.js +129 -34
- package/scripts/build.js +129 -0
- package/scripts/seed.js +517 -7
- package/server/routes/api/collections.js +301 -0
- package/server/routes/api/settings.js +66 -2
- package/server/server.js +19 -15
- package/server/services/collections.js +430 -0
- package/server/services/content.js +11 -2
- package/server/services/hooks.js +109 -0
- package/server/services/markdown.js +500 -149
- package/server/services/plugins.js +6 -1
- package/server/services/renderer.js +73 -7
- package/server/templates/page.html +38 -3
- package/plugins/back-to-top/admin/templates/back-to-top-settings.html +0 -55
- package/plugins/back-to-top/admin/views/back-to-top-settings.js +0 -44
- package/plugins/back-to-top/config.js +0 -10
- package/plugins/back-to-top/plugin.js +0 -24
- package/plugins/back-to-top/plugin.json +0 -36
- package/plugins/back-to-top/public/inject-body.html +0 -105
- package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +0 -113
- package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +0 -73
- package/plugins/cookie-consent/config.js +0 -30
- package/plugins/cookie-consent/plugin.js +0 -24
- package/plugins/cookie-consent/plugin.json +0 -36
- package/plugins/cookie-consent/public/inject-body.html +0 -69
- package/plugins/custom-css/admin/templates/custom-css.html +0 -17
- package/plugins/custom-css/admin/views/custom-css.js +0 -35
- package/plugins/custom-css/config.js +0 -1
- package/plugins/custom-css/data/custom.css +0 -0
- package/plugins/custom-css/plugin.js +0 -63
- package/plugins/custom-css/plugin.json +0 -32
- package/plugins/custom-css/public/inject-head.html +0 -1
- package/plugins/form-builder/data/forms/contact.json +0 -52
- package/plugins/form-builder/data/submissions/contact.json +0 -14
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import matter from 'gray-matter';
|
|
6
6
|
import {marked} from 'marked';
|
|
7
7
|
import sanitizeHtml from 'sanitize-html';
|
|
8
|
+
import {applyTransforms, getSanitizeExtensions, getShortcodeProcessors} from './hooks.js';
|
|
8
9
|
|
|
9
10
|
// Configure marked for safe output
|
|
10
11
|
marked.setOptions({
|
|
@@ -17,7 +18,7 @@ marked.setOptions({
|
|
|
17
18
|
* @param {string} attrStr
|
|
18
19
|
* @returns {object}
|
|
19
20
|
*/
|
|
20
|
-
function parseShortcodeAttrs(attrStr) {
|
|
21
|
+
export function parseShortcodeAttrs(attrStr) {
|
|
21
22
|
const attrs = {};
|
|
22
23
|
for (const [, key, val] of attrStr.matchAll(/(\w+)=["']([^"']*)["']/g)) {
|
|
23
24
|
attrs[key] = val;
|
|
@@ -26,136 +27,59 @@ function parseShortcodeAttrs(attrStr) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.*.
|
|
30
|
+
* Temporarily replace fenced code blocks and inline code spans with
|
|
31
|
+
* non-matching placeholders so shortcode regexes cannot fire inside them.
|
|
32
|
+
* Returns the scrubbed string and a restore function.
|
|
37
33
|
*
|
|
38
34
|
* @param {string} markdown
|
|
39
|
-
* @returns {string}
|
|
35
|
+
* @returns {{ scrubbed: string, restore: (s: string) => string }}
|
|
40
36
|
*/
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
);
|
|
37
|
+
export function scrubCodeRegions(markdown) {
|
|
38
|
+
const store = [];
|
|
39
|
+
const placeholder = (s) => {
|
|
40
|
+
const idx = store.length;
|
|
41
|
+
store.push(s);
|
|
42
|
+
return `\x02SC${idx}\x03`;
|
|
43
|
+
};
|
|
77
44
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
);
|
|
45
|
+
// Fenced code blocks (``` or ~~~, with optional language tag)
|
|
46
|
+
let scrubbed = markdown.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1[ \t]*$/gm, placeholder);
|
|
47
|
+
// Inline code spans (single or multiple backticks, non-greedy)
|
|
48
|
+
scrubbed = scrubbed.replace(/`+[^`\n]+`+/g, placeholder);
|
|
89
49
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
/\[fireworks([^\]]*)\]([\s\S]*?)\[\/fireworks\]/gi,
|
|
94
|
-
(_, _attrStr, body) => `<div class="fireworks-container">${body.trim()}</div>\n`
|
|
95
|
-
);
|
|
50
|
+
const restore = (s) => s.replace(/\x02SC(\d+)\x03/g, (_, i) => store[parseInt(i, 10)]);
|
|
51
|
+
return {scrubbed, restore};
|
|
52
|
+
}
|
|
96
53
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const innerHtml = marked.parse(processCardBlocks(processGridBlocks(body.trim())));
|
|
109
|
-
return `<div class="${classes.join(' ')}">${innerHtml}</div>\n`;
|
|
110
|
-
}
|
|
111
|
-
);
|
|
54
|
+
/**
|
|
55
|
+
* Run all plugin-registered shortcode handlers against the markdown string.
|
|
56
|
+
* Self-closing ([name attrs /]) and wrapping ([name attrs]...[/name]) forms are both supported.
|
|
57
|
+
* Code regions are scrubbed first so shortcodes inside fenced blocks are never processed.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} markdown
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function processPluginShortcodes(markdown) {
|
|
63
|
+
const processors = getShortcodeProcessors();
|
|
64
|
+
if (!processors.length) return markdown;
|
|
112
65
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
66
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
67
|
+
let result = scrubbed;
|
|
68
|
+
const context = {parseShortcodeAttrs, scrubCodeRegions, marked, processCardBlocks, processGridBlocks, escapeAttr};
|
|
116
69
|
|
|
117
|
-
for (const name of
|
|
70
|
+
for (const {name, handler} of processors) {
|
|
71
|
+
// Self-closing: [name attrs /]
|
|
72
|
+
result = result.replace(
|
|
73
|
+
new RegExp(`\\[${name}([^\\]]*)\\s*\\/\\]`, 'gi'),
|
|
74
|
+
(_, attrStr) => handler(attrStr, null, context)
|
|
75
|
+
);
|
|
76
|
+
// Wrapping: [name attrs]...[/name]
|
|
118
77
|
result = result.replace(
|
|
119
78
|
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
|
-
}
|
|
79
|
+
(_, attrStr, body) => handler(attrStr, body, context)
|
|
128
80
|
);
|
|
129
81
|
}
|
|
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;
|
|
82
|
+
return restore(result);
|
|
159
83
|
}
|
|
160
84
|
|
|
161
85
|
/**
|
|
@@ -185,7 +109,8 @@ function processGridBlocks(markdown) {
|
|
|
185
109
|
(_, attrStr, body) => {
|
|
186
110
|
const attrs = parseShortcodeAttrs(attrStr);
|
|
187
111
|
const cls = attrs.span ? ` class="col-span-${attrs.span}"` : '';
|
|
188
|
-
|
|
112
|
+
const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
113
|
+
return `<div${cls}${id}>${marked.parse(processCardBlocks(body.trim()))}</div>`;
|
|
189
114
|
}
|
|
190
115
|
);
|
|
191
116
|
|
|
@@ -197,7 +122,8 @@ function processGridBlocks(markdown) {
|
|
|
197
122
|
const classes = ['row'];
|
|
198
123
|
if (attrs.gap) classes.push(`gap-${attrs.gap}`);
|
|
199
124
|
if (attrs.class) classes.push(attrs.class);
|
|
200
|
-
|
|
125
|
+
const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
126
|
+
return `<div class="${classes.join(' ')}"${id}>${inner}</div>\n`;
|
|
201
127
|
}
|
|
202
128
|
);
|
|
203
129
|
|
|
@@ -210,13 +136,29 @@ function processGridBlocks(markdown) {
|
|
|
210
136
|
if (attrs.cols) classes.push(`grid-cols-${attrs.cols}`);
|
|
211
137
|
if (attrs.gap) classes.push(`gap-${attrs.gap}`);
|
|
212
138
|
if (attrs.class) classes.push(attrs.class);
|
|
213
|
-
|
|
139
|
+
const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
140
|
+
return `<div class="${classes.join(' ')}"${id}>${inner}</div>\n`;
|
|
214
141
|
}
|
|
215
142
|
);
|
|
216
143
|
|
|
217
144
|
return result;
|
|
218
145
|
}
|
|
219
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Escape a string for safe use inside an HTML double-quoted attribute value.
|
|
149
|
+
* Prevents attribute-injection from shortcode attribute values.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} str
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function escapeAttr(str) {
|
|
155
|
+
return String(str)
|
|
156
|
+
.replace(/&/g, '&')
|
|
157
|
+
.replace(/"/g, '"')
|
|
158
|
+
.replace(/</g, '<')
|
|
159
|
+
.replace(/>/g, '>');
|
|
160
|
+
}
|
|
161
|
+
|
|
220
162
|
/**
|
|
221
163
|
* Pre-process [card] shortcodes before running through marked.
|
|
222
164
|
*
|
|
@@ -244,10 +186,396 @@ function processCardBlocks(markdown) {
|
|
|
244
186
|
? `<div class="card-header"><h2>${title}</h2></div>`
|
|
245
187
|
: '';
|
|
246
188
|
const bodyHtml = marked.parse(body.trim());
|
|
189
|
+
const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
247
190
|
const extra = collapsible ? ' data-collapsible="true"' : '';
|
|
248
|
-
return `<div class="card mb-4"${extra}>${titleHtml}<div class="card-body">${bodyHtml}</div></div>\n`;
|
|
191
|
+
return `<div class="card mb-4"${extra}${id}>${titleHtml}<div class="card-body">${bodyHtml}</div></div>\n`;
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Pre-process [tabs] / [tab] shortcodes before running through marked.
|
|
198
|
+
*
|
|
199
|
+
* Syntax:
|
|
200
|
+
* [tabs id="optional" style="pills"]
|
|
201
|
+
* [tab title="First"]Content[/tab]
|
|
202
|
+
* [tab title="Second"]Content[/tab]
|
|
203
|
+
* [/tabs]
|
|
204
|
+
*
|
|
205
|
+
* Supported attributes on [tabs]:
|
|
206
|
+
* style - "pills" for pill-style nav (default: standard underline)
|
|
207
|
+
* id - optional id on the wrapper
|
|
208
|
+
*
|
|
209
|
+
* @param {string} markdown
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
function processTabsBlocks(markdown) {
|
|
213
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
214
|
+
let counter = 0;
|
|
215
|
+
const processed = scrubbed.replace(
|
|
216
|
+
/\[tabs([^\]]*)\]([\s\S]*?)\[\/tabs\]/gi,
|
|
217
|
+
(_, attrStr, body) => {
|
|
218
|
+
counter++;
|
|
219
|
+
const prefix = `dm-tab-${counter}`;
|
|
220
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
221
|
+
const pillsClass = attrs.style === 'pills' ? ' tabs-pills' : '';
|
|
222
|
+
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
223
|
+
|
|
224
|
+
// Parse inner [tab title="..."] items
|
|
225
|
+
let tabIdx = 0;
|
|
226
|
+
const items = [];
|
|
227
|
+
body.replace(/\[tab([^\]]*)\]([\s\S]*?)\[\/tab\]/gi, (__, tabAttr, tabBody) => {
|
|
228
|
+
tabIdx++;
|
|
229
|
+
const tabAttrs = parseShortcodeAttrs(tabAttr);
|
|
230
|
+
const title = tabAttrs.title || `Tab ${tabIdx}`;
|
|
231
|
+
const paneId = `${prefix}-${tabIdx}`;
|
|
232
|
+
const bodyHtml = marked.parse(processCardBlocks(processGridBlocks(tabBody.trim())));
|
|
233
|
+
items.push({title, paneId, bodyHtml, first: tabIdx === 1});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (!items.length) return '';
|
|
237
|
+
|
|
238
|
+
const navItems = items.map(t =>
|
|
239
|
+
`<button class="tab-item${t.first ? ' active' : ''}">${escapeAttr(t.title)}</button>`
|
|
240
|
+
).join('\n ');
|
|
241
|
+
const panes = items.map(t =>
|
|
242
|
+
`<div class="tab-panel${t.first ? ' active' : ''}">${t.bodyHtml}</div>`
|
|
243
|
+
).join('\n ');
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
`<div class="tabs${pillsClass}"${idAttr}>\n` +
|
|
247
|
+
` <div class="tab-list">\n ${navItems}\n </div>\n` +
|
|
248
|
+
` <div class="tab-content">\n ${panes}\n </div>\n` +
|
|
249
|
+
`</div>\n`
|
|
250
|
+
);
|
|
249
251
|
}
|
|
250
252
|
);
|
|
253
|
+
return restore(processed);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Pre-process [accordion] / [item] shortcodes before running through marked.
|
|
258
|
+
*
|
|
259
|
+
* Syntax:
|
|
260
|
+
* [accordion multiple="true" id="optional"]
|
|
261
|
+
* [item title="Section 1"]Content[/item]
|
|
262
|
+
* [item title="Section 2"]Content[/item]
|
|
263
|
+
* [/accordion]
|
|
264
|
+
*
|
|
265
|
+
* Supported attributes on [accordion]:
|
|
266
|
+
* multiple - "true" to allow multiple panels open simultaneously
|
|
267
|
+
* id - optional id on the wrapper
|
|
268
|
+
*
|
|
269
|
+
* @param {string} markdown
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
function processAccordionBlocks(markdown) {
|
|
273
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
274
|
+
const processed = scrubbed.replace(
|
|
275
|
+
/\[accordion([^\]]*)\]([\s\S]*?)\[\/accordion\]/gi,
|
|
276
|
+
(_, attrStr, body) => {
|
|
277
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
278
|
+
const multiAttr = attrs.multiple === 'true' ? ' data-multi="true"' : '';
|
|
279
|
+
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
280
|
+
|
|
281
|
+
let items = '';
|
|
282
|
+
body.replace(/\[item([^\]]*)\]([\s\S]*?)\[\/item\]/gi, (__, itemAttr, itemBody) => {
|
|
283
|
+
const itemAttrs = parseShortcodeAttrs(itemAttr);
|
|
284
|
+
const title = itemAttrs.title || 'Item';
|
|
285
|
+
const bodyHtml = marked.parse(processCardBlocks(processGridBlocks(itemBody.trim())));
|
|
286
|
+
items += (
|
|
287
|
+
`<div class="accordion-item">\n` +
|
|
288
|
+
` <h3 class="accordion-header"><button class="accordion-button" type="button">${escapeAttr(title)}` +
|
|
289
|
+
`<span class="accordion-icon" data-icon="chevron-down"></span></button></h3>\n` +
|
|
290
|
+
` <div class="accordion-body"><div class="accordion-content">${bodyHtml}</div></div>\n` +
|
|
291
|
+
`</div>\n`
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return `<div class="accordion"${idAttr}${multiAttr}>\n${items}</div>\n`;
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
return restore(processed);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Pre-process [carousel] / [slide] shortcodes before running through marked.
|
|
303
|
+
*
|
|
304
|
+
* Syntax:
|
|
305
|
+
* [carousel autoplay="true" interval="5000" loop="true" animation="slide" id="optional"]
|
|
306
|
+
* [slide title="Optional" image="/media/photo.jpg"]Body content[/slide]
|
|
307
|
+
* [slide]Text-only slide content[/slide]
|
|
308
|
+
* [/carousel]
|
|
309
|
+
*
|
|
310
|
+
* Supported attributes on [carousel]:
|
|
311
|
+
* autoplay - "true" to auto-advance slides
|
|
312
|
+
* interval - milliseconds between slides (default 5000)
|
|
313
|
+
* loop - "false" to disable loop (default true)
|
|
314
|
+
* animation - "fade" or "slide" (default slide)
|
|
315
|
+
* id - optional id on the wrapper
|
|
316
|
+
*
|
|
317
|
+
* Supported attributes on [slide]:
|
|
318
|
+
* image - URL of a background/header image
|
|
319
|
+
* title - slide heading text
|
|
320
|
+
*
|
|
321
|
+
* @param {string} markdown
|
|
322
|
+
* @returns {string}
|
|
323
|
+
*/
|
|
324
|
+
function processCarouselBlocks(markdown) {
|
|
325
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
326
|
+
const processed = scrubbed.replace(
|
|
327
|
+
/\[carousel([^\]]*)\]([\s\S]*?)\[\/carousel\]/gi,
|
|
328
|
+
(_, attrStr, body) => {
|
|
329
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
330
|
+
const dataAttrs = [
|
|
331
|
+
attrs.autoplay ? ` data-autoplay="${escapeAttr(attrs.autoplay)}"` : '',
|
|
332
|
+
attrs.interval ? ` data-interval="${escapeAttr(attrs.interval)}"` : '',
|
|
333
|
+
attrs.loop ? ` data-loop="${escapeAttr(attrs.loop)}"` : '',
|
|
334
|
+
attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : ''
|
|
335
|
+
].join('');
|
|
336
|
+
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
337
|
+
|
|
338
|
+
let slides = '';
|
|
339
|
+
body.replace(/\[slide([^\]]*)\]([\s\S]*?)\[\/slide\]/gi, (__, slideAttr, slideBody) => {
|
|
340
|
+
const slideAttrs = parseShortcodeAttrs(slideAttr);
|
|
341
|
+
let inner = '';
|
|
342
|
+
if (slideAttrs.image) {
|
|
343
|
+
inner += `<img src="${escapeAttr(slideAttrs.image)}" alt="${escapeAttr(slideAttrs.title || '')}">\n`;
|
|
344
|
+
}
|
|
345
|
+
const contentHtml = marked.parse(processCardBlocks(processGridBlocks(slideBody.trim())));
|
|
346
|
+
if (slideAttrs.title || slideBody.trim()) {
|
|
347
|
+
inner += `<div class="carousel-slide-content">`;
|
|
348
|
+
if (slideAttrs.title) inner += `<h2 class="carousel-slide-title">${escapeAttr(slideAttrs.title)}</h2>`;
|
|
349
|
+
if (slideBody.trim()) inner += `<div class="carousel-slide-description">${contentHtml}</div>`;
|
|
350
|
+
inner += `</div>`;
|
|
351
|
+
}
|
|
352
|
+
slides += `<div class="carousel-slide">${inner}</div>\n`;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
return `<div class="carousel"${idAttr}${dataAttrs}><div class="carousel-track">${slides}</div></div>\n`;
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
return restore(processed);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Pre-process [countdown] self-closing shortcodes before running through marked.
|
|
363
|
+
*
|
|
364
|
+
* Syntax:
|
|
365
|
+
* [countdown to="2026-12-31" format="DD:HH:mm:ss" /]
|
|
366
|
+
* [countdown duration="300" format="mm:ss" /]
|
|
367
|
+
*
|
|
368
|
+
* Supported attributes:
|
|
369
|
+
* to - ISO date string for a fixed target date
|
|
370
|
+
* duration - seconds to count down from (used if to is absent)
|
|
371
|
+
* format - display format: "mm:ss", "HH:mm:ss", "DD:HH:mm:ss" (default mm:ss)
|
|
372
|
+
* id - optional id on the element
|
|
373
|
+
*
|
|
374
|
+
* Client-side site.js initialises .dm-countdown elements via E.timer().
|
|
375
|
+
*
|
|
376
|
+
* @param {string} markdown
|
|
377
|
+
* @returns {string}
|
|
378
|
+
*/
|
|
379
|
+
function processCountdownBlocks(markdown) {
|
|
380
|
+
return markdown.replace(
|
|
381
|
+
/\[countdown([^\]]*?)\/\]/gi,
|
|
382
|
+
(_, attrStr) => {
|
|
383
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
384
|
+
const dataAttrs = [
|
|
385
|
+
attrs.to ? ` data-to="${escapeAttr(attrs.to)}"` : '',
|
|
386
|
+
attrs.duration ? ` data-duration="${escapeAttr(attrs.duration)}"` : '',
|
|
387
|
+
attrs.format ? ` data-format="${escapeAttr(attrs.format)}"` : ''
|
|
388
|
+
].join('');
|
|
389
|
+
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
390
|
+
return `<div class="dm-countdown"${idAttr}${dataAttrs}></div>`;
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Pre-process [table] shortcodes before running through marked.
|
|
397
|
+
*
|
|
398
|
+
* Wraps a GFM Markdown table with Domma CSS classes and a responsive container.
|
|
399
|
+
*
|
|
400
|
+
* Syntax:
|
|
401
|
+
* [table striped="true" bordered="true" compact="true" caption="Sales Data"]
|
|
402
|
+
* | Product | Price |
|
|
403
|
+
* | ------- | ----: |
|
|
404
|
+
* | Widget | $9.99 |
|
|
405
|
+
* [/table]
|
|
406
|
+
*
|
|
407
|
+
* Supported attributes:
|
|
408
|
+
* striped - "true" → .table-striped
|
|
409
|
+
* bordered - "true" → .table-bordered
|
|
410
|
+
* compact - "true" → .table-compact
|
|
411
|
+
* caption - text inserted as <caption>
|
|
412
|
+
* class - extra classes appended to .table
|
|
413
|
+
* id - id attribute on the <table>
|
|
414
|
+
*
|
|
415
|
+
* @param {string} markdown
|
|
416
|
+
* @returns {string}
|
|
417
|
+
*/
|
|
418
|
+
function processTableBlocks(markdown) {
|
|
419
|
+
return markdown.replace(
|
|
420
|
+
/\[table([^\]]*)\]([\s\S]*?)\[\/table\]/gi,
|
|
421
|
+
(_, attrStr, body) => {
|
|
422
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
423
|
+
const classes = ['table'];
|
|
424
|
+
if (attrs.striped === 'true') classes.push('table-striped');
|
|
425
|
+
if (attrs.bordered === 'true') classes.push('table-bordered');
|
|
426
|
+
if (attrs.compact === 'true') classes.push('table-compact');
|
|
427
|
+
if (attrs.class) classes.push(attrs.class);
|
|
428
|
+
|
|
429
|
+
const caption = attrs.caption
|
|
430
|
+
? `<caption>${escapeAttr(attrs.caption)}</caption>`
|
|
431
|
+
: '';
|
|
432
|
+
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
433
|
+
|
|
434
|
+
let tableHtml = marked.parse(body.trim());
|
|
435
|
+
|
|
436
|
+
tableHtml = tableHtml.replace(
|
|
437
|
+
'<table>',
|
|
438
|
+
`<table class="${classes.join(' ')}"${idAttr}>${caption}`
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
return `<div class="table-responsive">${tableHtml}</div>\n`;
|
|
442
|
+
}
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Pre-process [hero] shortcodes before running through marked.
|
|
448
|
+
*
|
|
449
|
+
* Syntax:
|
|
450
|
+
* [hero title="Welcome" tagline="Build something great" size="lg" variant="gradient-blue"]
|
|
451
|
+
* Optional body content with **Markdown** support.
|
|
452
|
+
* [/hero]
|
|
453
|
+
*
|
|
454
|
+
* Supported attributes:
|
|
455
|
+
* title - Hero heading (.hero-title)
|
|
456
|
+
* tagline - Subtitle text (.hero-subtitle)
|
|
457
|
+
* size - "sm", "lg", "full" → .hero-sm / .hero-lg / .hero-full
|
|
458
|
+
* variant - "dark", "primary", "gradient-blue", "gradient-purple",
|
|
459
|
+
* "gradient-sunset", "gradient-ocean" → .hero-{variant}
|
|
460
|
+
* image - URL for background-image + adds .hero-cover
|
|
461
|
+
* overlay - "light", "dark", "darker", "gradient", "gradient-reverse" → .hero-overlay-{overlay}
|
|
462
|
+
* align - "center" (default) or "left" → .hero-center / .hero-left
|
|
463
|
+
* class - Extra classes appended to .hero
|
|
464
|
+
* id - Element id attribute
|
|
465
|
+
*
|
|
466
|
+
* @param {string} markdown
|
|
467
|
+
* @returns {string}
|
|
468
|
+
*/
|
|
469
|
+
function processHeroBlocks(markdown) {
|
|
470
|
+
return markdown.replace(
|
|
471
|
+
/\[hero([^\]]*)\]([\s\S]*?)\[\/hero\]/gi,
|
|
472
|
+
(_, attrStr, body) => {
|
|
473
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
474
|
+
const title = attrs.title || '';
|
|
475
|
+
const tagline = attrs.tagline || '';
|
|
476
|
+
const size = attrs.size || '';
|
|
477
|
+
const variant = attrs.variant || '';
|
|
478
|
+
const image = attrs.image || '';
|
|
479
|
+
const overlay = attrs.overlay || '';
|
|
480
|
+
const align = attrs.align || 'center';
|
|
481
|
+
const fullwidth = attrs.fullwidth === 'true';
|
|
482
|
+
const cls = attrs.class || '';
|
|
483
|
+
const id = attrs.id || '';
|
|
484
|
+
|
|
485
|
+
const classes = ['hero'];
|
|
486
|
+
if (size) classes.push(`hero-${size}`);
|
|
487
|
+
if (variant) classes.push(`hero-${variant}`);
|
|
488
|
+
if (align) classes.push(`hero-${align}`);
|
|
489
|
+
if (image) classes.push('hero-cover');
|
|
490
|
+
if (overlay) classes.push(`hero-overlay-${overlay}`);
|
|
491
|
+
if (fullwidth) classes.push('hero-breakout');
|
|
492
|
+
if (cls) classes.push(cls);
|
|
493
|
+
|
|
494
|
+
const style = image ? ` style="background-image:url('${escapeAttr(image)}')"` : '';
|
|
495
|
+
const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
|
|
496
|
+
|
|
497
|
+
const processedBody = processCardBlocks(processGridBlocks(body.trim()));
|
|
498
|
+
|
|
499
|
+
let inner = '<div class="hero-content">';
|
|
500
|
+
if (title) inner += `<h1 class="hero-title hero-title-responsive">${escapeAttr(title)}</h1>`;
|
|
501
|
+
if (tagline) inner += `<p class="hero-subtitle hero-subtitle-responsive">${escapeAttr(tagline)}</p>`;
|
|
502
|
+
if (processedBody) inner += `<div class="hero-body">${marked.parse(processedBody)}</div>`;
|
|
503
|
+
inner += '</div>';
|
|
504
|
+
|
|
505
|
+
return `<div class="${classes.join(' ')}"${idAttr}${style}>${inner}</div>\n`;
|
|
506
|
+
}
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Pre-process [slideover] shortcodes before running through marked.
|
|
512
|
+
*
|
|
513
|
+
* Syntax:
|
|
514
|
+
* [slideover title="More Info" trigger="Read more" size="md" position="right"]
|
|
515
|
+
* Markdown body here.
|
|
516
|
+
* [/slideover]
|
|
517
|
+
*
|
|
518
|
+
* Outputs a trigger button and a hidden content div with data attributes.
|
|
519
|
+
* Client-side site.js wires up the click handler to open a Domma slideover.
|
|
520
|
+
* Attribute values are HTML-escaped to prevent injection.
|
|
521
|
+
*
|
|
522
|
+
* @param {string} markdown
|
|
523
|
+
* @returns {string}
|
|
524
|
+
*/
|
|
525
|
+
function processSlideoverBlocks(markdown) {
|
|
526
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
527
|
+
let counter = 0;
|
|
528
|
+
const processed = scrubbed.replace(
|
|
529
|
+
/\[slideover([^\]]*)\]([\s\S]*?)\[\/slideover\]/gi,
|
|
530
|
+
(_, attrStr, body) => {
|
|
531
|
+
counter++;
|
|
532
|
+
const id = `dm-so-${counter}`;
|
|
533
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
534
|
+
const title = escapeAttr(attrs.title || '');
|
|
535
|
+
const trigger = escapeAttr(attrs.trigger || 'Open');
|
|
536
|
+
const size = escapeAttr(attrs.size || 'md');
|
|
537
|
+
const position = escapeAttr(attrs.position || 'right');
|
|
538
|
+
const bodyHtml = marked.parse(processCardBlocks(processGridBlocks(body.trim())));
|
|
539
|
+
return (
|
|
540
|
+
`<button class="btn btn-link dm-so-trigger" data-so-target="${id}">${trigger}</button>\n` +
|
|
541
|
+
`<div class="dm-so-content" id="${id}" style="display:none" ` +
|
|
542
|
+
`data-so-title="${title}" data-so-size="${size}" data-so-position="${position}">${bodyHtml}</div>\n`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
);
|
|
546
|
+
return restore(processed);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Pre-process [dconfig] shortcodes before running through marked.
|
|
551
|
+
*
|
|
552
|
+
* Extracts JSON config blocks and base64-encodes them into hidden divs.
|
|
553
|
+
* Multiple blocks on one page are supported and merged client-side.
|
|
554
|
+
* Invalid JSON blocks are silently dropped (no render output).
|
|
555
|
+
*
|
|
556
|
+
* Syntax:
|
|
557
|
+
* [dconfig]
|
|
558
|
+
* { "#btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }
|
|
559
|
+
* [/dconfig]
|
|
560
|
+
*
|
|
561
|
+
* @param {string} markdown
|
|
562
|
+
* @returns {string}
|
|
563
|
+
*/
|
|
564
|
+
function processDConfigBlocks(markdown) {
|
|
565
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
566
|
+
const processed = scrubbed.replace(
|
|
567
|
+
/\[dconfig\]([\s\S]*?)\[\/dconfig\]/gi,
|
|
568
|
+
(_, jsonStr) => {
|
|
569
|
+
try {
|
|
570
|
+
JSON.parse(jsonStr.trim()); // validate before encoding
|
|
571
|
+
const encoded = Buffer.from(jsonStr.trim(), 'utf8').toString('base64');
|
|
572
|
+
return `<div class="dm-page-config" style="display:none" data-config="${encoded}"></div>\n`;
|
|
573
|
+
} catch {
|
|
574
|
+
return ''; // invalid JSON — drop silently
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
);
|
|
578
|
+
return restore(processed);
|
|
251
579
|
}
|
|
252
580
|
|
|
253
581
|
/**
|
|
@@ -257,32 +585,55 @@ function processCardBlocks(markdown) {
|
|
|
257
585
|
* @returns {{ data: object, content: string, html: string }}
|
|
258
586
|
*/
|
|
259
587
|
export function parseMarkdown(raw) {
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
588
|
+
const {data, content} = matter(raw);
|
|
589
|
+
const extensions = getSanitizeExtensions();
|
|
590
|
+
|
|
591
|
+
// Pipeline:
|
|
592
|
+
// beforeParse → dconfig → plugin shortcodes → tabs → accordion → carousel → countdown
|
|
593
|
+
// → hero → grid → card → slideover → marked → sanitize → afterParse
|
|
594
|
+
const preprocessed = applyTransforms('markdown:beforeParse', content);
|
|
595
|
+
const withDconfig = processDConfigBlocks(preprocessed);
|
|
596
|
+
const withPluginShortcodes = processPluginShortcodes(withDconfig);
|
|
597
|
+
const withTabs = processTabsBlocks(withPluginShortcodes);
|
|
598
|
+
const withAccordion = processAccordionBlocks(withTabs);
|
|
599
|
+
const withCarousel = processCarouselBlocks(withAccordion);
|
|
600
|
+
const withCountdown = processCountdownBlocks(withCarousel);
|
|
601
|
+
const withHero = processHeroBlocks(withCountdown);
|
|
602
|
+
const withTable = processTableBlocks(withHero);
|
|
603
|
+
const withGrid = processGridBlocks(withTable);
|
|
604
|
+
const withCard = processCardBlocks(withGrid);
|
|
605
|
+
const withSlideover = processSlideoverBlocks(withCard);
|
|
606
|
+
const rendered = marked.parse(withSlideover);
|
|
607
|
+
|
|
608
|
+
const sanitized = sanitizeHtml(rendered, {
|
|
609
|
+
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
|
610
|
+
'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
611
|
+
'form', 'input', 'textarea', 'select', 'option', 'optgroup',
|
|
612
|
+
'button', 'label', 'fieldset', 'legend',
|
|
613
|
+
...extensions.tags
|
|
614
|
+
]),
|
|
615
|
+
allowedAttributes: {
|
|
616
|
+
...sanitizeHtml.defaults.allowedAttributes,
|
|
617
|
+
'*': ['class', 'id', 'style', 'data-*'],
|
|
618
|
+
img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
|
|
619
|
+
form: ['action', 'method'],
|
|
620
|
+
input: ['type', 'name', 'placeholder', 'value', 'required', 'disabled',
|
|
621
|
+
'readonly', 'min', 'max', 'step', 'pattern', 'maxlength',
|
|
622
|
+
'minlength', 'checked', 'autocomplete'],
|
|
623
|
+
textarea: ['name', 'placeholder', 'rows', 'cols', 'required',
|
|
624
|
+
'disabled', 'readonly', 'maxlength'],
|
|
625
|
+
select: ['name', 'required', 'disabled', 'multiple'],
|
|
626
|
+
option: ['value', 'selected', 'disabled'],
|
|
627
|
+
optgroup: ['label', 'disabled'],
|
|
628
|
+
button: ['type', 'disabled'],
|
|
629
|
+
label: ['for'],
|
|
630
|
+
fieldset: ['disabled'],
|
|
631
|
+
...extensions.attributes
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
const html = applyTransforms('markdown:afterParse', sanitized);
|
|
636
|
+
return {data, content, html};
|
|
286
637
|
}
|
|
287
638
|
|
|
288
639
|
/**
|