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.
Files changed (87) hide show
  1. package/admin/css/admin.css +78 -1
  2. package/admin/js/api.js +32 -0
  3. package/admin/js/app.js +24 -7
  4. package/admin/js/config/sidebar-config.js +8 -0
  5. package/admin/js/templates/collection-editor.html +80 -0
  6. package/admin/js/templates/collection-entries.html +36 -0
  7. package/admin/js/templates/collections.html +12 -0
  8. package/admin/js/templates/documentation.html +136 -0
  9. package/admin/js/templates/navigation.html +26 -4
  10. package/admin/js/templates/page-editor.html +91 -85
  11. package/admin/js/templates/settings.html +433 -172
  12. package/admin/js/views/collection-editor.js +487 -0
  13. package/admin/js/views/collection-entries.js +484 -0
  14. package/admin/js/views/collections.js +153 -0
  15. package/admin/js/views/dashboard.js +14 -6
  16. package/admin/js/views/index.js +9 -3
  17. package/admin/js/views/login.js +3 -2
  18. package/admin/js/views/navigation.js +77 -11
  19. package/admin/js/views/page-editor.js +207 -25
  20. package/admin/js/views/pages.js +14 -6
  21. package/admin/js/views/settings.js +137 -2
  22. package/admin/js/views/users.js +10 -7
  23. package/bin/cli.js +37 -10
  24. package/config/auth.json +2 -1
  25. package/config/content.json +1 -0
  26. package/config/navigation.json +14 -4
  27. package/config/plugins.json +0 -18
  28. package/config/presets.json +4 -8
  29. package/config/site.json +44 -3
  30. package/package.json +6 -2
  31. package/plugins/domma-effects/admin/templates/domma-effects.html +92 -3
  32. package/plugins/domma-effects/plugin.js +125 -0
  33. package/plugins/domma-effects/public/inject-body.html +19 -0
  34. package/plugins/example-analytics/admin/views/analytics.js +2 -2
  35. package/plugins/example-analytics/plugin.json +8 -0
  36. package/plugins/example-analytics/stats.json +15 -1
  37. package/plugins/form-builder/admin/templates/form-editor.html +19 -6
  38. package/plugins/form-builder/admin/views/form-editor.js +634 -9
  39. package/plugins/form-builder/admin/views/form-submissions.js +4 -4
  40. package/plugins/form-builder/admin/views/forms-list.js +5 -5
  41. package/plugins/form-builder/data/forms/consent.json +104 -0
  42. package/plugins/form-builder/data/forms/contacts.json +66 -0
  43. package/plugins/form-builder/data/submissions/consent.json +13 -0
  44. package/plugins/form-builder/data/submissions/contacts.json +26 -0
  45. package/plugins/form-builder/plugin.js +62 -11
  46. package/plugins/form-builder/plugin.json +12 -16
  47. package/plugins/form-builder/public/form-logic-engine.js +568 -0
  48. package/plugins/form-builder/public/inject-body.html +88 -6
  49. package/plugins/form-builder/public/inject-head.html +16 -0
  50. package/plugins/form-builder/public/package.json +1 -0
  51. package/public/css/site.css +113 -0
  52. package/public/js/btt.js +90 -0
  53. package/public/js/cookie-consent.js +61 -0
  54. package/public/js/site.js +129 -34
  55. package/scripts/build.js +129 -0
  56. package/scripts/seed.js +517 -7
  57. package/server/routes/api/collections.js +301 -0
  58. package/server/routes/api/settings.js +66 -2
  59. package/server/server.js +19 -15
  60. package/server/services/collections.js +430 -0
  61. package/server/services/content.js +11 -2
  62. package/server/services/hooks.js +109 -0
  63. package/server/services/markdown.js +500 -149
  64. package/server/services/plugins.js +6 -1
  65. package/server/services/renderer.js +73 -7
  66. package/server/templates/page.html +38 -3
  67. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +0 -55
  68. package/plugins/back-to-top/admin/views/back-to-top-settings.js +0 -44
  69. package/plugins/back-to-top/config.js +0 -10
  70. package/plugins/back-to-top/plugin.js +0 -24
  71. package/plugins/back-to-top/plugin.json +0 -36
  72. package/plugins/back-to-top/public/inject-body.html +0 -105
  73. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +0 -113
  74. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +0 -73
  75. package/plugins/cookie-consent/config.js +0 -30
  76. package/plugins/cookie-consent/plugin.js +0 -24
  77. package/plugins/cookie-consent/plugin.json +0 -36
  78. package/plugins/cookie-consent/public/inject-body.html +0 -69
  79. package/plugins/custom-css/admin/templates/custom-css.html +0 -17
  80. package/plugins/custom-css/admin/views/custom-css.js +0 -35
  81. package/plugins/custom-css/config.js +0 -1
  82. package/plugins/custom-css/data/custom.css +0 -0
  83. package/plugins/custom-css/plugin.js +0 -63
  84. package/plugins/custom-css/plugin.json +0 -32
  85. package/plugins/custom-css/public/inject-head.html +0 -1
  86. package/plugins/form-builder/data/forms/contact.json +0 -52
  87. 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
- * 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.*.
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 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
- );
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
- // 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
- );
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
- // 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
- );
50
+ const restore = (s) => s.replace(/\x02SC(\d+)\x03/g, (_, i) => store[parseInt(i, 10)]);
51
+ return {scrubbed, restore};
52
+ }
96
53
 
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
- );
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
- // 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'];
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 wrappingEffects) {
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
- return `<div${cls}>${marked.parse(processCardBlocks(body.trim()))}</div>`;
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
- return `<div class="${classes.join(' ')}">${inner}</div>\n`;
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
- return `<div class="${classes.join(' ')}">${inner}</div>\n`;
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, '&amp;')
157
+ .replace(/"/g, '&quot;')
158
+ .replace(/</g, '&lt;')
159
+ .replace(/>/g, '&gt;');
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
- 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 };
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
  /**