domma-cms 0.2.1 → 0.5.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 (166) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1200
  3. package/admin/dist/domma/domma-tools.css +2313 -0
  4. package/admin/dist/domma/domma-tools.min.js +10 -0
  5. package/admin/index.html +4 -0
  6. package/admin/js/api.js +1 -242
  7. package/admin/js/app.js +9 -279
  8. package/admin/js/config/sidebar-config.js +1 -115
  9. package/admin/js/lib/card.js +1 -63
  10. package/admin/js/lib/image-editor.js +1 -869
  11. package/admin/js/lib/markdown-toolbar.js +54 -421
  12. package/admin/js/templates/action-editor.html +171 -0
  13. package/admin/js/templates/actions-list.html +19 -0
  14. package/admin/js/templates/api-reference.html +1411 -0
  15. package/admin/js/templates/block-editor.html +158 -0
  16. package/admin/js/templates/blocks.html +8 -0
  17. package/admin/js/templates/collection-editor.html +47 -0
  18. package/admin/js/templates/collection-entries.html +3 -0
  19. package/admin/js/templates/collections.html +51 -4
  20. package/admin/js/templates/documentation.html +258 -0
  21. package/admin/js/templates/form-editor.html +238 -0
  22. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  23. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  24. package/admin/js/templates/layouts.html +44 -7
  25. package/admin/js/templates/login.html +29 -4
  26. package/admin/js/templates/my-profile.html +17 -0
  27. package/admin/js/templates/page-editor.html +48 -0
  28. package/admin/js/templates/pages.html +6 -1
  29. package/admin/js/templates/pro-docs.html +259 -0
  30. package/admin/js/templates/role-editor.html +59 -0
  31. package/admin/js/templates/roles.html +10 -0
  32. package/admin/js/templates/settings.html +137 -18
  33. package/admin/js/templates/tutorials.html +81 -0
  34. package/admin/js/templates/user-editor.html +7 -0
  35. package/admin/js/templates/users.html +3 -1
  36. package/admin/js/templates/view-editor.html +201 -0
  37. package/admin/js/templates/view-preview.html +51 -0
  38. package/admin/js/templates/views-list.html +19 -0
  39. package/admin/js/views/action-editor.js +1 -0
  40. package/admin/js/views/actions-list.js +1 -0
  41. package/admin/js/views/api-reference.js +1 -0
  42. package/admin/js/views/block-editor.js +8 -0
  43. package/admin/js/views/blocks.js +4 -0
  44. package/admin/js/views/collection-editor.js +3 -487
  45. package/admin/js/views/collection-entries.js +1 -484
  46. package/admin/js/views/collections.js +1 -153
  47. package/admin/js/views/dashboard.js +1 -56
  48. package/admin/js/views/documentation.js +1 -12
  49. package/admin/js/views/form-editor.js +8 -0
  50. package/admin/js/views/form-submissions.js +1 -0
  51. package/admin/js/views/forms.js +1 -0
  52. package/admin/js/views/index.js +1 -39
  53. package/admin/js/views/layouts.js +9 -42
  54. package/admin/js/views/login.js +7 -251
  55. package/admin/js/views/media.js +1 -240
  56. package/admin/js/views/my-profile.js +1 -0
  57. package/admin/js/views/navigation.js +14 -212
  58. package/admin/js/views/page-editor.js +72 -661
  59. package/admin/js/views/pages.js +5 -72
  60. package/admin/js/views/plugins.js +13 -90
  61. package/admin/js/views/pro-docs.js +1 -0
  62. package/admin/js/views/role-editor.js +1 -0
  63. package/admin/js/views/roles.js +4 -0
  64. package/admin/js/views/settings.js +3 -199
  65. package/admin/js/views/tutorials.js +1 -12
  66. package/admin/js/views/user-editor.js +1 -88
  67. package/admin/js/views/users.js +4 -76
  68. package/admin/js/views/view-editor.js +1 -0
  69. package/admin/js/views/view-preview.js +1 -0
  70. package/admin/js/views/views-list.js +1 -0
  71. package/bin/cli.js +1 -1
  72. package/config/auth.json +2 -17
  73. package/config/connections.json.bak +9 -0
  74. package/config/connections.json.example +9 -0
  75. package/config/navigation.json +15 -0
  76. package/config/plugins.json +19 -29
  77. package/config/server.json +6 -6
  78. package/config/site.json +17 -6
  79. package/package.json +24 -10
  80. package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
  81. package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
  82. package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
  83. package/plugins/domma-effects/public/celebrations/index.js +1 -535
  84. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
  85. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
  86. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
  87. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
  88. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
  89. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
  90. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
  91. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
  92. package/plugins/example-analytics/stats.json +21 -12
  93. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  94. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  95. package/plugins/theme-roller/config.js +1 -0
  96. package/plugins/theme-roller/plugin.js +233 -0
  97. package/plugins/theme-roller/plugin.json +31 -0
  98. package/plugins/theme-roller/public/active-theme.css +0 -0
  99. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  100. package/public/css/forms.css +1 -0
  101. package/public/css/site.css +1 -302
  102. package/public/js/btt.js +1 -90
  103. package/public/js/cookie-consent.js +1 -61
  104. package/public/js/form-logic-engine.js +1 -0
  105. package/public/js/forms.js +1 -0
  106. package/public/js/site.js +1 -204
  107. package/scripts/build.js +194 -129
  108. package/scripts/pro.js +254 -0
  109. package/scripts/reset.js +33 -8
  110. package/scripts/seed.js +343 -78
  111. package/scripts/setup.js +5 -4
  112. package/server/middleware/auth.js +136 -97
  113. package/server/routes/api/actions.js +200 -0
  114. package/server/routes/api/auth.js +292 -116
  115. package/server/routes/api/blocks.js +84 -0
  116. package/server/routes/api/collections.js +88 -23
  117. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +483 -505
  118. package/server/routes/api/layouts.js +49 -25
  119. package/server/routes/api/media.js +118 -93
  120. package/server/routes/api/navigation.js +40 -37
  121. package/server/routes/api/pages.js +132 -118
  122. package/server/routes/api/plugins.js +6 -3
  123. package/server/routes/api/settings.js +104 -89
  124. package/server/routes/api/users.js +27 -21
  125. package/server/routes/api/views.js +148 -0
  126. package/server/routes/public.js +124 -108
  127. package/server/server.js +269 -173
  128. package/server/services/actions.js +387 -0
  129. package/server/services/adapterRegistry.js +98 -0
  130. package/server/services/adapters/FileAdapter.js +192 -0
  131. package/server/services/adapters/MongoAdapter.js +220 -0
  132. package/server/services/blocks.js +162 -0
  133. package/server/services/collections.js +74 -86
  134. package/server/services/connectionManager.js +102 -0
  135. package/server/services/content.js +312 -307
  136. package/{plugins/form-builder → server/services}/email.js +126 -103
  137. package/server/services/forms.js +173 -0
  138. package/server/services/markdown.js +1378 -648
  139. package/server/services/permissionRegistry.js +173 -0
  140. package/server/services/presetCollections.js +251 -0
  141. package/server/services/renderer.js +75 -1
  142. package/server/services/roles.js +227 -0
  143. package/server/services/rowAccess.js +104 -0
  144. package/server/services/userProfiles.js +199 -0
  145. package/server/services/users.js +281 -212
  146. package/server/services/views.js +280 -0
  147. package/server/templates/page.html +119 -113
  148. package/plugins/form-builder/admin/templates/form-editor.html +0 -171
  149. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  150. package/plugins/form-builder/admin/views/form-editor.js +0 -1442
  151. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  152. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  153. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  154. package/plugins/form-builder/config.js +0 -9
  155. package/plugins/form-builder/data/forms/consent.json +0 -104
  156. package/plugins/form-builder/data/forms/contact-details.json +0 -63
  157. package/plugins/form-builder/data/forms/contacts.json +0 -66
  158. package/plugins/form-builder/data/submissions/consent.json +0 -13
  159. package/plugins/form-builder/data/submissions/contact-details.json +0 -1
  160. package/plugins/form-builder/data/submissions/contacts.json +0 -26
  161. package/plugins/form-builder/plugin.json +0 -52
  162. package/plugins/form-builder/public/form-logic-engine.js +0 -568
  163. package/plugins/form-builder/public/inject-body.html +0 -352
  164. package/plugins/form-builder/public/inject-head.html +0 -58
  165. package/plugins/form-builder/public/package.json +0 -1
  166. package/scripts/copy-domma.js +0 -48
@@ -1,648 +1,1378 @@
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
- import {applyTransforms, getSanitizeExtensions, getShortcodeProcessors} from './hooks.js';
9
-
10
- // Configure marked for safe output
11
- marked.setOptions({
12
- gfm: true,
13
- breaks: false
14
- });
15
-
16
- /**
17
- * Parse key="value" or key='value' pairs from a shortcode attribute string.
18
- * @param {string} attrStr
19
- * @returns {object}
20
- */
21
- export function parseShortcodeAttrs(attrStr) {
22
- const attrs = {};
23
- for (const [, key, val] of attrStr.matchAll(/(\w+)=["']([^"']*)["']/g)) {
24
- attrs[key] = val;
25
- }
26
- return attrs;
27
- }
28
-
29
- /**
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.
33
- *
34
- * @param {string} markdown
35
- * @returns {{ scrubbed: string, restore: (s: string) => string }}
36
- */
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
- };
44
-
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);
49
-
50
- const restore = (s) => s.replace(/\x02SC(\d+)\x03/g, (_, i) => store[parseInt(i, 10)]);
51
- return {scrubbed, restore};
52
- }
53
-
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;
65
-
66
- const {scrubbed, restore} = scrubCodeRegions(markdown);
67
- let result = scrubbed;
68
- const context = {parseShortcodeAttrs, scrubCodeRegions, marked, processCardBlocks, processGridBlocks, escapeAttr};
69
-
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]
77
- result = result.replace(
78
- new RegExp(`\\[${name}([^\\]]*)\\]([\\s\\S]*?)\\[\\/${name}\\]`, 'gi'),
79
- (_, attrStr, body) => handler(attrStr, body, context)
80
- );
81
- }
82
- return restore(result);
83
- }
84
-
85
- /**
86
- * Pre-process [row], [grid], and [col] shortcodes before running through marked.
87
- *
88
- * Uses Domma's native CSS Grid system — NOT the .col compatibility layer.
89
- *
90
- * Supported shortcodes:
91
- *
92
- * [grid cols="N" gap="N"]
93
- * [col span="N"] Content [/col]
94
- * [/grid]
95
- * → <div class="grid grid-cols-N gap-N"><div class="col-span-N">…</div></div>
96
- *
97
- * [row gap="N"]
98
- * [col] Content [/col]
99
- * [/row]
100
- * → <div class="row gap-N"><div>…</div></div>
101
- *
102
- * @param {string} markdown
103
- * @returns {string}
104
- */
105
- function processGridBlocks(markdown) {
106
- // Pass 1: [col span="N"]...[/col] → <div class="col-span-N">...</div>
107
- let result = markdown.replace(
108
- /\[col([^\]]*)\]([\s\S]*?)\[\/col\]/gi,
109
- (_, attrStr, body) => {
110
- const attrs = parseShortcodeAttrs(attrStr);
111
- const cls = attrs.span ? ` class="col-span-${attrs.span}"` : '';
112
- const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
113
- return `<div${cls}${id}>${marked.parse(processCardBlocks(body.trim()))}</div>`;
114
- }
115
- );
116
-
117
- // Pass 2: [row gap="N" class="extra"]...[/row] → <div class="row ...">...</div>
118
- result = result.replace(
119
- /\[row([^\]]*)\]([\s\S]*?)\[\/row\]/gi,
120
- (_, attrStr, inner) => {
121
- const attrs = parseShortcodeAttrs(attrStr);
122
- const classes = ['row'];
123
- if (attrs.gap) classes.push(`gap-${attrs.gap}`);
124
- if (attrs.class) classes.push(attrs.class);
125
- const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
126
- return `<div class="${classes.join(' ')}"${id}>${inner}</div>\n`;
127
- }
128
- );
129
-
130
- // Pass 3: [grid cols="N" gap="N"]...[/grid] <div class="grid grid-cols-N ...">...</div>
131
- result = result.replace(
132
- /\[grid([^\]]*)\]([\s\S]*?)\[\/grid\]/gi,
133
- (_, attrStr, inner) => {
134
- const attrs = parseShortcodeAttrs(attrStr);
135
- const classes = ['grid'];
136
- if (attrs.cols) classes.push(`grid-cols-${attrs.cols}`);
137
- if (attrs.gap) classes.push(`gap-${attrs.gap}`);
138
- if (attrs.class) classes.push(attrs.class);
139
- const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
140
- return `<div class="${classes.join(' ')}"${id}>${inner}</div>\n`;
141
- }
142
- );
143
-
144
- return result;
145
- }
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
-
162
- /**
163
- * Pre-process [card] shortcodes before running through marked.
164
- *
165
- * Syntax:
166
- * [card title="Optional Title" collapsible="true"]
167
- * Body content (supports Markdown).
168
- * [/card]
169
- *
170
- * Supported attributes:
171
- * title - Card header title (omit for no header)
172
- * collapsible - "true" to make the card body toggle on header click
173
- *
174
- * @param {string} markdown
175
- * @returns {string}
176
- */
177
- function processCardBlocks(markdown) {
178
- return markdown.replace(
179
- /\[card([^\]]*)\]([\s\S]*?)\[\/card\]/gi,
180
- (_, attrStr, body) => {
181
- const attrs = parseShortcodeAttrs(attrStr);
182
- const title = attrs.title?.trim() || '';
183
- const collapsible = attrs.collapsible === 'true';
184
-
185
- const titleHtml = title
186
- ? `<div class="card-header"><h2>${title}</h2></div>`
187
- : '';
188
- const bodyHtml = marked.parse(body.trim());
189
- const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
190
- const extra = collapsible ? ' data-collapsible="true"' : '';
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
- );
251
- }
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);
579
- }
580
-
581
- /**
582
- * Parse a Markdown file string into frontmatter data and rendered HTML.
583
- *
584
- * @param {string} raw - Raw file content (frontmatter + Markdown body)
585
- * @returns {{ data: object, content: string, html: string }}
586
- */
587
- export function parseMarkdown(raw) {
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};
637
- }
638
-
639
- /**
640
- * Serialise a page object back to a Markdown file string.
641
- *
642
- * @param {object} frontmatter - Page metadata fields
643
- * @param {string} body - Markdown body
644
- * @returns {string}
645
- */
646
- export function serialiseMarkdown(frontmatter, body) {
647
- return matter.stringify(body || '', frontmatter);
648
- }
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
+ import {readFile} from 'fs/promises';
9
+ import path from 'path';
10
+ import {fileURLToPath} from 'url';
11
+ import {applyTransforms, getSanitizeExtensions, getShortcodeProcessors} from './hooks.js';
12
+ import {getConfig} from '../config.js';
13
+ import {getCollection, listEntries} from './collections.js';
14
+
15
+ const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
16
+ const BLOCKS_DIR = path.resolve(__dirname_md, '../../content/blocks');
17
+
18
+ // Configure marked for safe output
19
+ marked.setOptions({
20
+ gfm: true,
21
+ breaks: false
22
+ });
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Collection shortcode helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function escapeHtmlText(str) {
29
+ return String(str ?? '')
30
+ .replace(/&/g, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;');
34
+ }
35
+
36
+ function sortEntries(entries, sort, order) {
37
+ const dir = order === 'asc' ? 1 : -1;
38
+ return [...entries].sort((a, b) => {
39
+ const av = sort === 'createdAt' ? (a.meta?.createdAt || '') : (a.data?.[sort] ?? '');
40
+ const bv = sort === 'createdAt' ? (b.meta?.createdAt || '') : (b.data?.[sort] ?? '');
41
+ return av < bv ? -dir : av > bv ? dir : 0;
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Load a block template by name from content/blocks/{name}.html.
47
+ * Throws if the file does not exist.
48
+ *
49
+ * @param {string} blockName
50
+ * @returns {Promise<string>}
51
+ */
52
+ async function loadBlockTemplate(blockName) {
53
+ const safe = path.basename(blockName).replace(/[^a-z0-9-]/g, '');
54
+ return readFile(path.join(BLOCKS_DIR, `${safe}.html`), 'utf8');
55
+ }
56
+
57
+ /**
58
+ * Render collection entries using a reusable HTML block template.
59
+ * Replaces {{fieldName}}, {{_id}}, {{_createdAt}}, {{_updatedAt}} placeholders.
60
+ *
61
+ * @param {Array} entries - Collection entries with { id, data, meta }
62
+ * @param {string} blockTemplate - Raw HTML template with {{placeholders}}
63
+ * @param {string} emptyMsg - Message to show when entries is empty
64
+ * @param {object|null} ctaOpts - Optional CTA button options
65
+ * @returns {string}
66
+ */
67
+ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts) {
68
+ if (!entries.length) {
69
+ return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
70
+ }
71
+
72
+ const items = entries.map(e => {
73
+ let html = blockTemplate.replace(/\{\{([\w_]+)\}\}/g, (_, key) => {
74
+ if (key === '_id') return escapeHtmlText(e.id ?? '');
75
+ if (key === '_createdAt') return escapeHtmlText(e.meta?.createdAt ?? '');
76
+ if (key === '_updatedAt') return escapeHtmlText(e.meta?.updatedAt ?? '');
77
+ return escapeHtmlText(e.data?.[key] ?? '');
78
+ });
79
+
80
+ if (ctaOpts) {
81
+ const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
82
+ let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
83
+ let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
84
+ if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
85
+ const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
86
+ html += `\n<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
87
+ }
88
+ return html;
89
+ });
90
+
91
+ return `<div class="dm-collection-display dm-collection-blocks">\n${items.join('\n')}\n</div>`;
92
+ }
93
+
94
+ function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
95
+ const columns = visibleFields.map(f => ({key: f.name, title: f.label || f.name}));
96
+ const rows = entries.map(e => {
97
+ const row = {};
98
+ visibleFields.forEach(f => {
99
+ row[f.name] = e.data?.[f.name] ?? '';
100
+ });
101
+ if (ctaOpts) row._entryId = e.id || '';
102
+ return row;
103
+ });
104
+ if (ctaOpts) columns.push({key: '_cta', title: ''});
105
+ const tableOpts = {
106
+ columns,
107
+ rows,
108
+ search: attrs.search !== 'false',
109
+ sortable: attrs.sortable !== 'false',
110
+ exportable: attrs.exportable === 'true',
111
+ pageSize: parseInt(attrs['page-size'], 10) || 25,
112
+ empty: attrs.empty || 'No entries found'
113
+ };
114
+ if (ctaOpts) tableOpts.ctaConfig = ctaOpts;
115
+ const payload = Buffer.from(JSON.stringify(tableOpts)).toString('base64');
116
+ return `<div class="dm-collection-display" data-collection-table data-slug="${escapeAttr(slug)}" data-payload="${payload}"></div>`;
117
+ }
118
+
119
+ function renderCollectionCards(entries, visibleFields, titleField, columns, emptyMsg, ctaOpts) {
120
+ const cols = ['2', '3', '4'].includes(String(columns)) ? columns : '3';
121
+ if (!entries.length) {
122
+ return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
123
+ }
124
+ const cards = entries.map(e => {
125
+ const title = titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '';
126
+ const body = visibleFields
127
+ .filter(f => f.name !== titleField)
128
+ .map(f => {
129
+ const val = escapeHtmlText(e.data?.[f.name] ?? '');
130
+ return val ? `<p><strong>${escapeHtmlText(f.label || f.name)}:</strong> ${val}</p>` : '';
131
+ }).join('');
132
+ let footer = '';
133
+ if (ctaOpts) {
134
+ const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
135
+ let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
136
+ let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
137
+ if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
138
+ const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
139
+ footer = `<div class="card-footer"><button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button></div>`;
140
+ }
141
+ return `<div class="card">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || '&nbsp;'}</div>${footer}</div>`;
142
+ }).join('\n');
143
+ return `<div class="dm-collection-display grid grid-cols-${cols} gap-4">\n${cards}\n</div>`;
144
+ }
145
+
146
+ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts) {
147
+ if (!entries.length) {
148
+ return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
149
+ }
150
+ const items = entries.map(e => {
151
+ const title = titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '';
152
+ const rest = visibleFields
153
+ .filter(f => f.name !== titleField)
154
+ .map(f => {
155
+ const val = escapeHtmlText(e.data?.[f.name] ?? '');
156
+ return val ? `<p>${val}</p>` : '';
157
+ }).join('');
158
+ let ctaHtml = '';
159
+ if (ctaOpts) {
160
+ const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
161
+ let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
162
+ let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
163
+ if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
164
+ const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
165
+ ctaHtml = `<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
166
+ }
167
+ return `<div class="dm-collection-list-item">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
168
+ }).join('\n');
169
+ return `<div class="dm-collection-display dm-collection-list">\n${items}\n</div>`;
170
+ }
171
+
172
+ /**
173
+ * Process [view slug="..." display="table|cards|list" /] shortcodes.
174
+ * Executes the View's aggregation pipeline and renders results using the
175
+ * same render functions as [collection].
176
+ *
177
+ * @param {string} markdown
178
+ * @returns {Promise<string>}
179
+ */
180
+ async function processViewBlocks(markdown) {
181
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
182
+ const pattern = /\[view([^\]]*?)\/\]/gi;
183
+ const matches = [...scrubbed.matchAll(pattern)];
184
+ if (!matches.length) return markdown;
185
+
186
+ let result = scrubbed;
187
+
188
+ for (const match of matches) {
189
+ const [fullMatch, attrStr] = match;
190
+ const attrs = parseShortcodeAttrs(attrStr);
191
+ const slug = attrs.slug || '';
192
+ if (!slug) {
193
+ result = result.replace(fullMatch, '');
194
+ continue;
195
+ }
196
+
197
+ const displayAttr = attrs.display || '';
198
+ const emptyMsg = attrs.empty || 'No results found';
199
+ const titleField = attrs['title-field'] || '';
200
+ const columns = attrs.columns || '3';
201
+ const limitAttr = parseInt(attrs.limit, 10) || 25;
202
+ const fieldFilter = attrs.fields ? attrs.fields.split(',').map(f => f.trim()).filter(Boolean) : null;
203
+
204
+ const ctaOpts = attrs.action ? {
205
+ action: attrs.action,
206
+ label: attrs['cta-label'] || 'Run',
207
+ style: attrs['cta-style'] || 'primary',
208
+ icon: attrs['cta-icon'] || '',
209
+ confirm: attrs['cta-confirm'] || ''
210
+ } : null;
211
+
212
+ let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
213
+
214
+ try {
215
+ const {executeView, getView} = await import('./views.js');
216
+ const [{results}, viewConfig] = await Promise.all([
217
+ executeView(slug, {page: 1, limit: limitAttr}),
218
+ getView(slug).catch(() => null)
219
+ ]);
220
+
221
+ // Map MongoDB docs to { id, data } shape. MongoAdapter docs have a top-level id field.
222
+ const entries = results.map(doc => ({id: doc.id || '', data: doc.data || doc}));
223
+
224
+ // Derive visible fields from attr filter or first entry's data keys
225
+ let fields;
226
+ if (fieldFilter?.length) {
227
+ fields = fieldFilter.map(name => ({ name, label: name }));
228
+ } else if (entries.length) {
229
+ fields = Object.keys(entries[0].data || {})
230
+ .filter(k => !['_id', 'id', 'meta'].includes(k))
231
+ .map(k => ({name: k, label: k}));
232
+ } else {
233
+ fields = [];
234
+ }
235
+
236
+ const display = displayAttr || viewConfig?.display?.mode || 'table';
237
+
238
+ if (display === 'cards') {
239
+ replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
240
+ } else if (display === 'list') {
241
+ replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
242
+ } else if (display === 'block') {
243
+ const blockName = attrs.block || viewConfig?.display?.block || '';
244
+ if (blockName) {
245
+ try {
246
+ const tpl = await loadBlockTemplate(blockName);
247
+ replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts);
248
+ } catch {
249
+ replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template &ldquo;${escapeHtmlText(blockName)}&rdquo; not found.</p></div>`;
250
+ }
251
+ }
252
+ } else {
253
+ replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts);
254
+ }
255
+ } catch {
256
+ // View not found, MongoDB not configured, or pipeline error — show empty state
257
+ }
258
+
259
+ result = result.replace(fullMatch, replacement);
260
+ }
261
+
262
+ return restore(result);
263
+ }
264
+
265
+ /**
266
+ * Process [collection slug="..." display="..." /] shortcodes.
267
+ * Fetches entries server-side and renders static HTML.
268
+ *
269
+ * @param {string} markdown
270
+ * @returns {Promise<string>}
271
+ */
272
+ async function processCollectionBlocks(markdown) {
273
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
274
+ // Find all [collection ...] shortcodes
275
+ const pattern = /\[collection([^\]]*?)\/\]/gi;
276
+ const matches = [...scrubbed.matchAll(pattern)];
277
+ if (!matches.length) return markdown;
278
+
279
+ let result = scrubbed;
280
+
281
+ for (const match of matches) {
282
+ const [fullMatch, attrStr] = match;
283
+ const attrs = parseShortcodeAttrs(attrStr);
284
+ const slug = attrs.slug || '';
285
+ if (!slug) {
286
+ result = result.replace(fullMatch, '');
287
+ continue;
288
+ }
289
+
290
+ const display = attrs.display || 'table';
291
+ const limitAttr = parseInt(attrs.limit, 10) || 0;
292
+ const sort = attrs.sort || 'createdAt';
293
+ const order = attrs.order || 'desc';
294
+ const titleField = attrs['title-field'] || '';
295
+ const columns = attrs.columns || '3';
296
+ const emptyMsg = attrs.empty || 'No entries found';
297
+ const fieldFilter = attrs.fields ? attrs.fields.split(',').map(f => f.trim()).filter(Boolean) : null;
298
+ const ctaAction = attrs.cta || '';
299
+ const ctaOpts = ctaAction ? {
300
+ action: ctaAction,
301
+ label: attrs['cta-label'] || 'Run',
302
+ icon: attrs['cta-icon'] || '',
303
+ style: attrs['cta-style'] || 'primary',
304
+ confirm: attrs['cta-confirm'] || ''
305
+ } : null;
306
+
307
+ let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
308
+
309
+ try {
310
+ const schema = await getCollection(slug);
311
+ if (!schema) throw new Error('not found');
312
+
313
+ let {entries} = await listEntries(slug);
314
+ entries = sortEntries(entries, sort, order);
315
+ if (limitAttr > 0) entries = entries.slice(0, limitAttr);
316
+
317
+ // Determine visible fields from schema (optionally filtered)
318
+ let fields = schema.fields || [];
319
+ if (fieldFilter?.length) {
320
+ fields = fieldFilter.map(name => fields.find(f => f.name === name) || { name, label: name });
321
+ }
322
+ if (!fields.length && entries.length) {
323
+ // No schema fields — derive from first entry's data keys
324
+ fields = Object.keys(entries[0].data || {}).map(k => ({ name: k, label: k }));
325
+ }
326
+
327
+ if (display === 'cards') {
328
+ replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
329
+ } else if (display === 'list') {
330
+ replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
331
+ } else if (display === 'block') {
332
+ const blockName = attrs.block || '';
333
+ if (blockName) {
334
+ try {
335
+ const tpl = await loadBlockTemplate(blockName);
336
+ replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts);
337
+ } catch {
338
+ replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template &ldquo;${escapeHtmlText(blockName)}&rdquo; not found.</p></div>`;
339
+ }
340
+ }
341
+ } else {
342
+ replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
343
+ }
344
+ } catch {
345
+ // Collection not found or read error — show empty message
346
+ }
347
+
348
+ result = result.replace(fullMatch, replacement);
349
+ }
350
+
351
+ return restore(result);
352
+ }
353
+
354
+ /**
355
+ * Parse key="value" or key='value' pairs from a shortcode attribute string.
356
+ * @param {string} attrStr
357
+ * @returns {object}
358
+ */
359
+ export function parseShortcodeAttrs(attrStr) {
360
+ const attrs = {};
361
+ // Pass 1: quoted key="value" or key='value' pairs
362
+ for (const [, key, val] of attrStr.matchAll(/([\w-]+)=["']([^"']*)["']/g)) {
363
+ attrs[key] = val;
364
+ }
365
+ // Pass 2: standalone flag attributes (no value) — blank out key=value matches first
366
+ const stripped = attrStr.replace(/([\w-]+)=["'][^"']*["']/g, m => ' '.repeat(m.length));
367
+ for (const [, key] of stripped.matchAll(/\b([\w-]+)\b/g)) {
368
+ if (!(key in attrs)) attrs[key] = true;
369
+ }
370
+ return attrs;
371
+ }
372
+
373
+ /**
374
+ * Temporarily replace fenced code blocks and inline code spans with
375
+ * non-matching placeholders so shortcode regexes cannot fire inside them.
376
+ * Returns the scrubbed string and a restore function.
377
+ *
378
+ * @param {string} markdown
379
+ * @returns {{ scrubbed: string, restore: (s: string) => string }}
380
+ */
381
+ export function scrubCodeRegions(markdown) {
382
+ const store = [];
383
+ const placeholder = (s) => {
384
+ const idx = store.length;
385
+ store.push(s);
386
+ return `\x02SC${idx}\x03`;
387
+ };
388
+
389
+ // HTML <pre><code>...</code></pre> blocks (rendered by earlier marked.parse calls inside accordion/carousel etc.)
390
+ let scrubbed = markdown.replace(/<pre\b[^>]*>[\s\S]*?<\/pre>/gi, placeholder);
391
+ // Fenced code blocks (``` or ~~~, with optional language tag)
392
+ scrubbed = scrubbed.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1[ \t]*$/gm, placeholder);
393
+ // Inline code spans (single or multiple backticks, non-greedy)
394
+ scrubbed = scrubbed.replace(/`+[^`\n]+`+/g, placeholder);
395
+
396
+ const restore = (s) => s.replace(/\x02SC(\d+)\x03/g, (_, i) => store[parseInt(i, 10)]);
397
+ return {scrubbed, restore};
398
+ }
399
+
400
+ /**
401
+ * Run all plugin-registered shortcode handlers against the markdown string.
402
+ * Self-closing ([name attrs /]) and wrapping ([name attrs]...[/name]) forms are both supported.
403
+ * Code regions are scrubbed first so shortcodes inside fenced blocks are never processed.
404
+ *
405
+ * @param {string} markdown
406
+ * @returns {string}
407
+ */
408
+ function processPluginShortcodes(markdown) {
409
+ const processors = getShortcodeProcessors();
410
+ if (!processors.length) return markdown;
411
+
412
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
413
+ let result = scrubbed;
414
+ const context = {parseShortcodeAttrs, scrubCodeRegions, marked, processCardBlocks, processGridBlocks, escapeAttr};
415
+
416
+ for (const {name, handler} of processors) {
417
+ // Self-closing: [name attrs /]
418
+ result = result.replace(
419
+ new RegExp(`\\[${name}([^\\]]*)\\s*\\/\\]`, 'gi'),
420
+ (_, attrStr) => handler(attrStr, null, context)
421
+ );
422
+ // Wrapping: [name attrs]...[/name]
423
+ result = result.replace(
424
+ new RegExp(`\\[${name}([^\\]]*)\\]([\\s\\S]*?)\\[\\/${name}\\]`, 'gi'),
425
+ (_, attrStr, body) => handler(attrStr, body, context)
426
+ );
427
+ }
428
+ return restore(result);
429
+ }
430
+
431
+ /**
432
+ * Pre-process [row], [grid], and [col] shortcodes before running through marked.
433
+ *
434
+ * Uses Domma's native CSS Grid system — NOT the .col compatibility layer.
435
+ *
436
+ * Supported shortcodes:
437
+ *
438
+ * [grid cols="N" gap="N"]
439
+ * [col span="N"] Content [/col]
440
+ * [/grid]
441
+ * → <div class="grid grid-cols-N gap-N"><div class="col-span-N">…</div></div>
442
+ *
443
+ * [row gap="N"]
444
+ * [col] Content [/col]
445
+ * [/row]
446
+ * → <div class="row gap-N"><div>…</div></div>
447
+ *
448
+ * @param {string} markdown
449
+ * @returns {string}
450
+ */
451
+ function processGridBlocks(markdown) {
452
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
453
+ // Pass 1: [col span="N"]...[/col] → <div class="col-span-N">...</div>
454
+ let result = scrubbed.replace(
455
+ /\[col([^\]]*)\]([\s\S]*?)\[\/col\]/gi,
456
+ (_, attrStr, body) => {
457
+ const attrs = parseShortcodeAttrs(attrStr);
458
+ const cls = attrs.span ? ` class="col-span-${attrs.span}"` : '';
459
+ const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
460
+ return `<div${cls}${id}>${marked.parse(processCardBlocks(body.trim()))}</div>`;
461
+ }
462
+ );
463
+
464
+ // Pass 2: [row gap="N" class="extra"]...[/row] → <div class="row ...">...</div>
465
+ result = result.replace(
466
+ /\[row([^\]]*)\]([\s\S]*?)\[\/row\]/gi,
467
+ (_, attrStr, inner) => {
468
+ const attrs = parseShortcodeAttrs(attrStr);
469
+ const classes = ['row'];
470
+ if (attrs.gap) classes.push(`gap-${attrs.gap}`);
471
+ if (attrs.class) classes.push(attrs.class);
472
+ const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
473
+ return `<div class="${classes.join(' ')}"${id}>${inner}</div>\n`;
474
+ }
475
+ );
476
+
477
+ // Pass 3: [grid cols="N" gap="N"]...[/grid] <div class="grid grid-cols-N ...">...</div>
478
+ result = result.replace(
479
+ /\[grid([^\]]*)\]([\s\S]*?)\[\/grid\]/gi,
480
+ (_, attrStr, inner) => {
481
+ const attrs = parseShortcodeAttrs(attrStr);
482
+ const classes = ['grid'];
483
+ if (attrs.cols) classes.push(`grid-cols-${attrs.cols}`);
484
+ if (attrs.gap) classes.push(`gap-${attrs.gap}`);
485
+ if (attrs.class) classes.push(attrs.class);
486
+ if (attrs.fullwidth === 'true') classes.push('grid-breakout');
487
+ const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
488
+ return `<div class="${classes.join(' ')}"${id}>${inner}</div>\n`;
489
+ }
490
+ );
491
+
492
+ return restore(result);
493
+ }
494
+
495
+ /**
496
+ * Escape a string for safe use inside an HTML double-quoted attribute value.
497
+ * Prevents attribute-injection from shortcode attribute values.
498
+ *
499
+ * @param {string} str
500
+ * @returns {string}
501
+ */
502
+ export function escapeAttr(str) {
503
+ return String(str)
504
+ .replace(/&/g, '&amp;')
505
+ .replace(/"/g, '&quot;')
506
+ .replace(/</g, '&lt;')
507
+ .replace(/>/g, '&gt;');
508
+ }
509
+
510
+ /**
511
+ * Pre-process [card] shortcodes before running through marked.
512
+ *
513
+ * Syntax:
514
+ * [card title="Optional Title" collapsible="true"]
515
+ * Body content (supports Markdown).
516
+ * [/card]
517
+ *
518
+ * Supported attributes:
519
+ * title - Card header title (omit for no header)
520
+ * collapsible - "true" to make the card body toggle on header click
521
+ *
522
+ * @param {string} markdown
523
+ * @returns {string}
524
+ */
525
+ function processCardBlocks(markdown) {
526
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
527
+ return restore(scrubbed.replace(
528
+ /\[card([^\]]*)\]([\s\S]*?)\[\/card\]/gi,
529
+ (_, attrStr, body) => {
530
+ const attrs = parseShortcodeAttrs(attrStr);
531
+ const strAttr = (key) => typeof attrs[key] === 'string' ? attrs[key].trim() : '';
532
+ const title = strAttr('title');
533
+ const subtitle = strAttr('subtitle');
534
+ const icon = strAttr('icon');
535
+ const footer = strAttr('footer');
536
+ const collapsible = attrs.collapsible === 'true';
537
+ const hover = 'hover' in attrs;
538
+ const variant = strAttr('variant');
539
+ const extraClass = strAttr('class');
540
+
541
+ // Root class list
542
+ const classes = ['card', 'mb-4'];
543
+ if (variant === 'primary') classes.push('card-primary');
544
+ if (hover) classes.push('card-hover');
545
+ if (collapsible) classes.push('card-collapsible');
546
+ if (extraClass) classes.push(extraClass);
547
+
548
+ const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
549
+ const coll = collapsible ? ' data-collapsible="true"' : '';
550
+
551
+ const iconLayout = (attrs['icon-layout'] || 'inline').trim(); // 'inline' | 'stacked'
552
+
553
+ // Header only rendered when at least title or icon is set
554
+ let headerHtml = '';
555
+ if (title || icon) {
556
+ let inner = '';
557
+ if (icon && iconLayout === 'stacked') {
558
+ // Stacked: icon centred above title, all centred
559
+ const subtitleHtml = subtitle ? `<div class="card-subtitle">${subtitle}</div>` : '';
560
+ inner = `<span data-icon="${escapeAttr(icon)}"></span>` +
561
+ `<div class="card-title">${title}</div>${subtitleHtml}`;
562
+ headerHtml = `<div class="card-header card-header-icon-stacked">${inner}</div>`;
563
+ } else if (icon && title) {
564
+ // Inline: icon left, title to its right in a flex row
565
+ const subtitleHtml = subtitle ? `<div class="card-subtitle">${subtitle}</div>` : '';
566
+ inner = `<span data-icon="${escapeAttr(icon)}"></span>` +
567
+ `<div class="card-header-content"><div class="card-title">${title}</div>${subtitleHtml}</div>`;
568
+ headerHtml = `<div class="card-header card-header-icon-inline">${inner}</div>`;
569
+ } else {
570
+ // Title only (no icon)
571
+ inner = `<div class="card-title">${title}</div>`;
572
+ if (subtitle) inner += `<div class="card-subtitle">${subtitle}</div>`;
573
+ headerHtml = `<div class="card-header">${inner}</div>`;
574
+ }
575
+ }
576
+
577
+ const bodyHtml = `<div class="card-body">${marked.parse(body.trim())}</div>`;
578
+ const footerHtml = footer ? `<div class="card-footer">${footer}</div>` : '';
579
+
580
+ return `<div class="${classes.join(' ')}"${coll}${id}>${headerHtml}${bodyHtml}${footerHtml}</div>\n`;
581
+ }
582
+ ));
583
+ }
584
+
585
+ /**
586
+ * Pre-process [tabs] / [tab] shortcodes before running through marked.
587
+ *
588
+ * Syntax:
589
+ * [tabs id="optional" style="pills"]
590
+ * [tab title="First"]Content[/tab]
591
+ * [tab title="Second"]Content[/tab]
592
+ * [/tabs]
593
+ *
594
+ * Supported attributes on [tabs]:
595
+ * style - "pills" for pill-style nav (default: standard underline)
596
+ * id - optional id on the wrapper
597
+ *
598
+ * @param {string} markdown
599
+ * @returns {string}
600
+ */
601
+ function processTabsBlocks(markdown) {
602
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
603
+ let counter = 0;
604
+ const processed = scrubbed.replace(
605
+ /\[tabs([^\]]*)\]([\s\S]*?)\[\/tabs\]/gi,
606
+ (_, attrStr, body) => {
607
+ counter++;
608
+ const prefix = `dm-tab-${counter}`;
609
+ const attrs = parseShortcodeAttrs(attrStr);
610
+ const pillsClass = attrs.style === 'pills' ? ' tabs-pills' : '';
611
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
612
+
613
+ // Parse inner [tab title="..."] items
614
+ let tabIdx = 0;
615
+ const items = [];
616
+ body.replace(/\[tab([^\]]*)\]([\s\S]*?)\[\/tab\]/gi, (__, tabAttr, tabBody) => {
617
+ tabIdx++;
618
+ const tabAttrs = parseShortcodeAttrs(tabAttr);
619
+ const title = tabAttrs.title || `Tab ${tabIdx}`;
620
+ const paneId = `${prefix}-${tabIdx}`;
621
+ // Restore code-block placeholders within this tab body before calling marked,
622
+ // so fenced code blocks render as <pre><code> rather than surviving as raw text
623
+ // that would confuse subsequent scrubCodeRegions calls.
624
+ const restoredBody = restore(tabBody.trim());
625
+ const bodyHtml = marked.parse(processCardBlocks(processGridBlocks(restoredBody)));
626
+ items.push({title, paneId, bodyHtml, first: tabIdx === 1});
627
+ });
628
+
629
+ if (!items.length) return '';
630
+
631
+ const navItems = items.map(t =>
632
+ `<button class="tab-item${t.first ? ' active' : ''}">${escapeAttr(t.title)}</button>`
633
+ ).join('\n ');
634
+ const panes = items.map(t =>
635
+ `<div class="tab-panel${t.first ? ' active' : ''}">${t.bodyHtml}</div>`
636
+ ).join('\n ');
637
+
638
+ return (
639
+ `<div class="tabs${pillsClass}"${idAttr}>\n` +
640
+ ` <div class="tab-list">\n ${navItems}\n </div>\n` +
641
+ ` <div class="tab-content">\n ${panes}\n </div>\n` +
642
+ `</div>\n`
643
+ );
644
+ }
645
+ );
646
+ return restore(processed);
647
+ }
648
+
649
+ /**
650
+ * Pre-process [accordion] / [item] shortcodes before running through marked.
651
+ *
652
+ * Syntax:
653
+ * [accordion multiple="true" id="optional"]
654
+ * [item title="Section 1"]Content[/item]
655
+ * [item title="Section 2"]Content[/item]
656
+ * [/accordion]
657
+ *
658
+ * Supported attributes on [accordion]:
659
+ * multiple - "true" to allow multiple panels open simultaneously
660
+ * id - optional id on the wrapper
661
+ *
662
+ * @param {string} markdown
663
+ * @returns {string}
664
+ */
665
+ function processAccordionBlocks(markdown) {
666
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
667
+ const processed = scrubbed.replace(
668
+ /\[accordion([^\]]*)\]([\s\S]*?)\[\/accordion\]/gi,
669
+ (_, attrStr, body) => {
670
+ const attrs = parseShortcodeAttrs(attrStr);
671
+ const multiAttr = attrs.multiple === 'true' ? ' data-multi="true"' : '';
672
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
673
+
674
+ let items = '';
675
+ body.replace(/\[item([^\]]*)\]([\s\S]*?)\[\/item\]/gi, (__, itemAttr, itemBody) => {
676
+ const itemAttrs = parseShortcodeAttrs(itemAttr);
677
+ const title = itemAttrs.title || 'Item';
678
+ const bodyHtml = marked.parse(processCardBlocks(processGridBlocks(restore(itemBody.trim()))));
679
+ items += (
680
+ `<div class="accordion-item">\n` +
681
+ ` <h3 class="accordion-header"><button class="accordion-button" type="button">${escapeAttr(title)}` +
682
+ `<span class="accordion-icon" data-icon="chevron-down"></span></button></h3>\n` +
683
+ ` <div class="accordion-body"><div class="accordion-content">${bodyHtml}</div></div>\n` +
684
+ `</div>\n`
685
+ );
686
+ });
687
+
688
+ return `<div class="accordion"${idAttr}${multiAttr}>\n${items}</div>\n`;
689
+ }
690
+ );
691
+ return restore(processed);
692
+ }
693
+
694
+ /**
695
+ * Pre-process [carousel] / [slide] shortcodes before running through marked.
696
+ *
697
+ * Syntax:
698
+ * [carousel autoplay="true" interval="5000" loop="true" animation="slide" id="optional"]
699
+ * [slide title="Optional" image="/media/photo.jpg"]Body content[/slide]
700
+ * [slide]Text-only slide content[/slide]
701
+ * [/carousel]
702
+ *
703
+ * Supported attributes on [carousel]:
704
+ * autoplay - "true" to auto-advance slides
705
+ * interval - milliseconds between slides (default 5000)
706
+ * loop - "false" to disable loop (default true)
707
+ * animation - "fade" or "slide" (default slide)
708
+ * id - optional id on the wrapper
709
+ *
710
+ * Supported attributes on [slide]:
711
+ * image - URL of a background/header image
712
+ * title - slide heading text
713
+ *
714
+ * @param {string} markdown
715
+ * @returns {string}
716
+ */
717
+ function processCarouselBlocks(markdown) {
718
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
719
+ const processed = scrubbed.replace(
720
+ /\[carousel([^\]]*)\]([\s\S]*?)\[\/carousel\]/gi,
721
+ (_, attrStr, body) => {
722
+ const attrs = parseShortcodeAttrs(attrStr);
723
+ const dataAttrs = [
724
+ attrs.autoplay ? ` data-autoplay="${escapeAttr(attrs.autoplay)}"` : '',
725
+ attrs.interval ? ` data-interval="${escapeAttr(attrs.interval)}"` : '',
726
+ attrs.loop ? ` data-loop="${escapeAttr(attrs.loop)}"` : '',
727
+ attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : ''
728
+ ].join('');
729
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
730
+
731
+ let slides = '';
732
+ body.replace(/\[slide([^\]]*)\]([\s\S]*?)\[\/slide\]/gi, (__, slideAttr, slideBody) => {
733
+ const slideAttrs = parseShortcodeAttrs(slideAttr);
734
+ let inner = '';
735
+ if (slideAttrs.image) {
736
+ inner += `<img src="${escapeAttr(slideAttrs.image)}" alt="${escapeAttr(slideAttrs.title || '')}">\n`;
737
+ }
738
+ const contentHtml = marked.parse(processCardBlocks(processGridBlocks(restore(slideBody.trim()))));
739
+ if (slideAttrs.title || slideBody.trim()) {
740
+ inner += `<div class="carousel-slide-content">`;
741
+ if (slideAttrs.title) inner += `<h2 class="carousel-slide-title">${escapeAttr(slideAttrs.title)}</h2>`;
742
+ if (slideBody.trim()) inner += `<div class="carousel-slide-description">${contentHtml}</div>`;
743
+ inner += `</div>`;
744
+ }
745
+ slides += `<div class="carousel-slide">${inner}</div>\n`;
746
+ });
747
+
748
+ return `<div class="carousel"${idAttr}${dataAttrs}><div class="carousel-track">${slides}</div></div>\n`;
749
+ }
750
+ );
751
+ return restore(processed);
752
+ }
753
+
754
+ /**
755
+ * Pre-process [countdown] self-closing shortcodes before running through marked.
756
+ *
757
+ * Syntax:
758
+ * [countdown to="2026-12-31" format="DD:HH:mm:ss" /]
759
+ * [countdown duration="300" format="mm:ss" /]
760
+ *
761
+ * Supported attributes:
762
+ * to - ISO date string for a fixed target date
763
+ * duration - seconds to count down from (used if to is absent)
764
+ * format - display format: "mm:ss", "HH:mm:ss", "DD:HH:mm:ss" (default mm:ss)
765
+ * id - optional id on the element
766
+ *
767
+ * Client-side site.js initialises .dm-countdown elements via E.timer().
768
+ *
769
+ * @param {string} markdown
770
+ * @returns {string}
771
+ */
772
+ function processCountdownBlocks(markdown) {
773
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
774
+ return restore(scrubbed.replace(
775
+ /\[countdown([^\]]*?)\/\]/gi,
776
+ (_, attrStr) => {
777
+ const attrs = parseShortcodeAttrs(attrStr);
778
+ const dataAttrs = [
779
+ attrs.to ? ` data-to="${escapeAttr(attrs.to)}"` : '',
780
+ attrs.duration ? ` data-duration="${escapeAttr(attrs.duration)}"` : '',
781
+ attrs.format ? ` data-format="${escapeAttr(attrs.format)}"` : ''
782
+ ].join('');
783
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
784
+ return `<div class="dm-countdown"${idAttr}${dataAttrs}></div>`;
785
+ }
786
+ ));
787
+ }
788
+
789
+ /**
790
+ * Pre-process [timeline] / [event] shortcodes before running through marked.
791
+ *
792
+ * Renders a Domma Progression (dm-progression) component with event items.
793
+ *
794
+ * Syntax:
795
+ * [timeline layout="vertical" theme="modern" mode="timeline"]
796
+ * [event title="Launch" date="2025-01-15" status="completed" icon="rocket"]
797
+ * Description with **Markdown**.
798
+ * [/event]
799
+ * [/timeline]
800
+ *
801
+ * Supported [timeline] attributes:
802
+ * layout - "vertical" (default), "centred", "horizontal"
803
+ * theme - "minimal" (default), "corporate", "modern"
804
+ * mode - "timeline" (default), "roadmap"
805
+ * class - extra CSS classes
806
+ * id - element id
807
+ *
808
+ * Supported [event] attributes:
809
+ * title - Event heading (required)
810
+ * date - Display date string
811
+ * status - "planned", "in-progress", "completed", "blocked"
812
+ * icon - Domma icon name
813
+ *
814
+ * @param {string} markdown
815
+ * @returns {string}
816
+ */
817
+ function processTimelineBlocks(markdown) {
818
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
819
+ const result = scrubbed.replace(
820
+ /\[timeline([^\]]*)\]([\s\S]*?)\[\/timeline\]/gi,
821
+ (_, attrStr, body) => {
822
+ const attrs = parseShortcodeAttrs(attrStr);
823
+ const layout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
824
+ const theme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
825
+ const mode = ['timeline', 'roadmap'].includes(attrs.mode) ? attrs.mode : 'timeline';
826
+
827
+ const classes = ['dm-progression'];
828
+ if (attrs.class) classes.push(attrs.class);
829
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
830
+
831
+ // Parse [event]...[/event] items from body
832
+ const itemsHtml = body.replace(
833
+ /\[event([^\]]*)\]([\s\S]*?)\[\/event\]/gi,
834
+ (__, itemAttrStr, itemBody) => {
835
+ const ia = parseShortcodeAttrs(itemAttrStr);
836
+ const title = escapeAttr(ia.title || '');
837
+ const date = ia.date ? ` data-date="${escapeAttr(ia.date)}"` : '';
838
+ const status = ia.status ? ` data-status="${escapeAttr(ia.status)}"` : '';
839
+ const icon = ia.icon ? ` data-icon="${escapeAttr(ia.icon)}"` : '';
840
+ const descHtml = marked.parse(processCardBlocks(processGridBlocks(restore(itemBody.trim()))));
841
+ return `<div class="dm-progression-item"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${descHtml}</div></div>`;
842
+ }
843
+ );
844
+
845
+ return `<div class="${classes.join(' ')}"${idAttr} data-layout="${layout}" data-theme="${theme}" data-mode="${mode}">\n${itemsHtml}</div>\n`;
846
+ }
847
+ );
848
+ return restore(result);
849
+ }
850
+
851
+ /**
852
+ * Pre-process [table] shortcodes before running through marked.
853
+ *
854
+ * Wraps a GFM Markdown table with Domma CSS classes and a responsive container.
855
+ *
856
+ * Syntax:
857
+ * [table striped="true" bordered="true" compact="true" caption="Sales Data"]
858
+ * | Product | Price |
859
+ * | ------- | ----: |
860
+ * | Widget | $9.99 |
861
+ * [/table]
862
+ *
863
+ * Supported attributes:
864
+ * striped - "true" → .table-striped
865
+ * bordered - "true" → .table-bordered
866
+ * compact - "true" → .table-compact
867
+ * caption - text inserted as <caption>
868
+ * class - extra classes appended to .table
869
+ * id - id attribute on the <table>
870
+ *
871
+ * @param {string} markdown
872
+ * @returns {string}
873
+ */
874
+ /**
875
+ * Pre-process [badge] inline shortcodes before running through marked.
876
+ *
877
+ * Syntax (paired):
878
+ * [badge variant="success"]New[/badge]
879
+ * [badge variant="danger" outline pill]Deprecated[/badge]
880
+ * [badge variant="warning" size="small"]Beta[/badge]
881
+ *
882
+ * Syntax (self-closing, renders empty badge — useful for coloured dots):
883
+ * [badge variant="primary" pill /]
884
+ *
885
+ * Supported attributes:
886
+ * variant - primary (default), secondary, success, danger, warning, info, light, dark
887
+ * pill - flag: add .badge-pill (rounded)
888
+ * outline - flag: add .badge-outline
889
+ * size - "small" → .badge-sm | "large" → .badge-lg
890
+ *
891
+ * @param {string} markdown
892
+ * @returns {string}
893
+ */
894
+ function processBadgeBlocks(markdown) {
895
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
896
+
897
+ const VALID_VARIANTS = new Set([
898
+ 'primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'
899
+ ]);
900
+
901
+ function buildBadge(attrStr, inner) {
902
+ const attrs = parseShortcodeAttrs(attrStr || '');
903
+ const variant = VALID_VARIANTS.has(attrs.variant) ? attrs.variant : 'primary';
904
+ const classes = ['badge', `badge-${variant}`];
905
+
906
+ // Flag attributes (presence in the raw string, not key=value)
907
+ if (/\bpill\b/i.test(attrStr)) classes.push('badge-pill');
908
+ if (/\boutline\b/i.test(attrStr)) classes.push('badge-outline');
909
+ if (attrs.size === 'small') classes.push('badge-sm');
910
+ if (attrs.size === 'large') classes.push('badge-lg');
911
+
912
+ return `<span class="${classes.join(' ')}">${inner}</span>`;
913
+ }
914
+
915
+ // Self-closing: [badge ... /]
916
+ let result = scrubbed.replace(/\[badge([^\]]*?)\/\]/gi, (_, attrStr) => buildBadge(attrStr, ''));
917
+
918
+ // Paired: [badge ...]...[/badge] (inline only — no newlines inside)
919
+ result = result.replace(/\[badge([^\]]*)\]([^\n]*?)\[\/badge\]/gi, (_, attrStr, inner) => {
920
+ return buildBadge(attrStr, inner.trim());
921
+ });
922
+
923
+ return restore(result);
924
+ }
925
+
926
+ function processTableBlocks(markdown) {
927
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
928
+ return restore(scrubbed.replace(
929
+ /\[table([^\]]*)\]([\s\S]*?)\[\/table\]/gi,
930
+ (_, attrStr, body) => {
931
+ const attrs = parseShortcodeAttrs(attrStr);
932
+ const classes = ['table'];
933
+ if (attrs.striped === 'true') classes.push('table-striped');
934
+ if (attrs.bordered === 'true') classes.push('table-bordered');
935
+ if (attrs.compact === 'true') classes.push('table-compact');
936
+ if (attrs.class) classes.push(attrs.class);
937
+
938
+ const caption = attrs.caption
939
+ ? `<caption>${escapeAttr(attrs.caption)}</caption>`
940
+ : '';
941
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
942
+
943
+ let tableHtml = marked.parse(restore(body.trim()));
944
+
945
+ tableHtml = tableHtml.replace(
946
+ '<table>',
947
+ `<table class="${classes.join(' ')}"${idAttr}>${caption}`
948
+ );
949
+
950
+ return `<div class="table-responsive">${tableHtml}</div>\n`;
951
+ }
952
+ ));
953
+ }
954
+
955
+ /**
956
+ * Pre-process [hero] shortcodes before running through marked.
957
+ *
958
+ * Syntax:
959
+ * [hero title="Welcome" tagline="Build something great" size="lg" variant="gradient-blue"]
960
+ * Optional body content with **Markdown** support.
961
+ * [/hero]
962
+ *
963
+ * Supported attributes:
964
+ * title - Hero heading (.hero-title)
965
+ * tagline - Subtitle text (.hero-subtitle)
966
+ * size - "sm", "lg", "full" → .hero-sm / .hero-lg / .hero-full
967
+ * variant - "dark", "primary", "gradient-blue", "gradient-purple",
968
+ * "gradient-sunset", "gradient-ocean" → .hero-{variant}
969
+ * image - URL for background-image + adds .hero-cover
970
+ * overlay - "light", "dark", "darker", "gradient", "gradient-reverse" → .hero-overlay-{overlay}
971
+ * align - "center" (default) or "left" → .hero-center / .hero-left
972
+ * class - Extra classes appended to .hero
973
+ * id - Element id attribute
974
+ *
975
+ * @param {string} markdown
976
+ * @returns {string}
977
+ */
978
+ /**
979
+ * Pre-process [spacer] self-closing shortcode.
980
+ *
981
+ * Syntax:
982
+ * [spacer /] - uses defaults from layoutOptions config
983
+ * [spacer size="24" /] - explicit pixel height
984
+ * [spacer class="my-gap" /] - extra CSS class
985
+ * [spacer size="16" class="mt-4" /] - both
986
+ *
987
+ * @param {string} markdown
988
+ * @returns {string}
989
+ */
990
+ function processSpacerBlocks(markdown) {
991
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
992
+ const opts = getConfig('site')?.layoutOptions ?? {};
993
+ const defaultSize = opts.spacerSize ?? 8;
994
+ const defaultClass = opts.spacerClass ?? '';
995
+ return restore(scrubbed.replace(
996
+ /\[spacer([^\]]*?)\/?\]/gi,
997
+ (_, attrStr) => {
998
+ const attrs = parseShortcodeAttrs(attrStr || '');
999
+ const size = parseInt(attrs.size, 10) || defaultSize;
1000
+ const extra = attrs.class || defaultClass;
1001
+ const classes = ['dm-spacer', extra].filter(Boolean).join(' ');
1002
+ return `<div class="${classes}" style="height:${size}px" aria-hidden="true"></div>\n`;
1003
+ }
1004
+ ));
1005
+ }
1006
+
1007
+ /**
1008
+ * Pre-process [center]...[/center] shortcodes before running through marked.
1009
+ *
1010
+ * Syntax:
1011
+ * [center]Centred content here[/center]
1012
+ * [center class="my-class"]Content[/center]
1013
+ *
1014
+ * Supported attributes:
1015
+ * class - Extra CSS classes on the wrapper div
1016
+ *
1017
+ * @param {string} markdown
1018
+ * @returns {string}
1019
+ */
1020
+ function processCenterBlocks(markdown) {
1021
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
1022
+ return restore(scrubbed.replace(
1023
+ /\[center([^\]]*)\]([\s\S]*?)\[\/center\]/gi,
1024
+ (_, attrStr, body) => {
1025
+ const attrs = parseShortcodeAttrs(attrStr);
1026
+ const classAttr = attrs.class ? ` class="${escapeAttr(attrs.class)}"` : '';
1027
+ const bodyHtml = marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))));
1028
+ return `<div style="text-align:center;"${classAttr}>${bodyHtml}</div>\n`;
1029
+ }
1030
+ ));
1031
+ }
1032
+
1033
+ /**
1034
+ * Pre-process [icon] self-closing shortcodes before running through marked.
1035
+ *
1036
+ * Syntax:
1037
+ * [icon name="arrow-right" /]
1038
+ * [icon name="star" size="24" color="#f59e0b" class="my-icon" /]
1039
+ *
1040
+ * Supported attributes:
1041
+ * name - Domma icon name (required). Also accepted as src= for convenience.
1042
+ * size - Width and height in px (default: 1em via CSS)
1043
+ * color - CSS colour applied via style
1044
+ * class - Extra CSS classes
1045
+ *
1046
+ * @param {string} markdown
1047
+ * @returns {string}
1048
+ */
1049
+ function processIconBlocks(markdown) {
1050
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
1051
+ return restore(scrubbed.replace(
1052
+ /\[icon([^\]]*?)\/\]/gi,
1053
+ (_, attrStr) => {
1054
+ const attrs = parseShortcodeAttrs(attrStr);
1055
+ const name = attrs.name || attrs.src || '';
1056
+ if (!name) return '';
1057
+ const classes = ['dm-icon', attrs.class].filter(Boolean).join(' ');
1058
+ const sizeAttr = attrs.size ? ` data-icon-size="${parseInt(attrs.size, 10)}"` : '';
1059
+ const colorAttr = attrs.color ? ` data-icon-colour="${escapeAttr(attrs.color)}"` : '';
1060
+ return `<span data-icon="${escapeAttr(name)}" class="${escapeAttr(classes)}"${sizeAttr}${colorAttr}></span>`;
1061
+ }
1062
+ ));
1063
+ }
1064
+
1065
+ /**
1066
+ * Pre-process [form] self-closing shortcodes before running through marked.
1067
+ *
1068
+ * Syntax:
1069
+ * [form name="contact" /]
1070
+ * [form name="newsletter" class="my-form" /]
1071
+ *
1072
+ * Supported attributes:
1073
+ * name - Form slug as configured in the form-builder (required). Also accepted as slug=.
1074
+ * class - Extra CSS classes on the wrapper div
1075
+ * id - Element id attribute
1076
+ *
1077
+ * The form-builder's injected client script scans for [data-form] and renders the form.
1078
+ *
1079
+ * @param {string} markdown
1080
+ * @returns {string}
1081
+ */
1082
+ const FORMS_DIR = path.resolve(__dirname_md, '../../content/forms');
1083
+
1084
+ async function processFormBlocks(markdown) {
1085
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
1086
+ const regex = /\[form([^\]]*?)\/\]/gi;
1087
+ let result = scrubbed;
1088
+ const matches = [];
1089
+ let m;
1090
+ while ((m = regex.exec(scrubbed)) !== null) {
1091
+ matches.push({full: m[0], attrStr: m[1], index: m.index});
1092
+ }
1093
+ for (let i = matches.length - 1; i >= 0; i--) {
1094
+ const {full, attrStr, index} = matches[i];
1095
+ const attrs = parseShortcodeAttrs(attrStr);
1096
+ const slug = attrs.name || attrs.slug || '';
1097
+ if (!slug) {
1098
+ result = result.slice(0, index) + '' + result.slice(index + full.length);
1099
+ continue;
1100
+ }
1101
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(slug)) {
1102
+ result = result.slice(0, index) + `<div class="cms-form-error">Invalid form slug: ${escapeAttr(slug)}</div>` + result.slice(index + full.length);
1103
+ continue;
1104
+ }
1105
+ let replacement;
1106
+ try {
1107
+ const filePath = path.resolve(FORMS_DIR, `${slug}.json`);
1108
+ if (!filePath.startsWith(FORMS_DIR + path.sep)) throw new Error('Invalid slug');
1109
+ const raw = await readFile(filePath, 'utf8');
1110
+ const form = JSON.parse(raw);
1111
+ const encoded = Buffer.from(JSON.stringify(form)).toString('base64');
1112
+ const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
1113
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
1114
+ replacement = `<div class="cms-form-embed${extraClass}" data-form-inline="${escapeAttr(encoded)}"${idAttr}></div>`;
1115
+ } catch {
1116
+ const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
1117
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
1118
+ replacement = `<div class="cms-form-embed cms-form-embed--error${extraClass}" data-form-slug="${escapeAttr(slug)}"${idAttr}><p><em>Form not found: ${escapeHtmlText(slug)}</em></p></div>`;
1119
+ }
1120
+ result = result.slice(0, index) + replacement + result.slice(index + full.length);
1121
+ }
1122
+ return restore(result);
1123
+ }
1124
+
1125
+ function processHeroBlocks(markdown) {
1126
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
1127
+ const result = scrubbed.replace(
1128
+ /\[hero([^\]]*)\]([\s\S]*?)\[\/hero\]/gi,
1129
+ (_, attrStr, body) => {
1130
+ const attrs = parseShortcodeAttrs(attrStr);
1131
+ const title = attrs.title || '';
1132
+ const tagline = attrs.tagline || '';
1133
+ const size = attrs.size || '';
1134
+ const variant = attrs.variant || '';
1135
+ const image = attrs.image || '';
1136
+ const overlay = attrs.overlay || '';
1137
+ const align = attrs.align || 'center';
1138
+ const fullwidth = attrs.fullwidth === 'true';
1139
+ const cls = attrs.class || '';
1140
+ const id = attrs.id || '';
1141
+ const bg = attrs.bg || '';
1142
+
1143
+ const twinkle = 'twinkle' in attrs;
1144
+ const twinkleCount = attrs['twinkle-count'] || '';
1145
+ const twinkleColour = attrs['twinkle-colour'] || '';
1146
+ const blobs = 'blobs' in attrs;
1147
+ const blobsType = attrs['blobs-type'] || 'float-blobs';
1148
+
1149
+ const classes = ['hero'];
1150
+ if (size) classes.push(`hero-${size}`);
1151
+ if (variant) classes.push(`hero-${variant}`);
1152
+ if (align) classes.push(`hero-${align}`);
1153
+ if (image) classes.push('hero-cover');
1154
+ if (overlay) classes.push(`hero-overlay-${overlay}`);
1155
+ if (fullwidth) classes.push('hero-breakout');
1156
+ if (cls) classes.push(cls);
1157
+ if (twinkle) classes.push('dm-fx-twinkle');
1158
+ if (blobs) classes.push(`bg-ambient-${blobsType}`);
1159
+
1160
+ const styleParts = [];
1161
+ if (image) styleParts.push(`background-image:url('${escapeAttr(image)}')`);
1162
+ if (bg) styleParts.push(`background-color:${escapeAttr(bg)}`);
1163
+ const style = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
1164
+ const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
1165
+
1166
+ const twinkleAttrs = twinkle
1167
+ ? (twinkleCount ? ` data-fx-count="${escapeAttr(twinkleCount)}"` : '') +
1168
+ (twinkleColour ? ` data-fx-colour="${escapeAttr(twinkleColour)}"` : '')
1169
+ : '';
1170
+
1171
+ const processedBody = processBadgeBlocks(processCardBlocks(processGridBlocks(restore(body.trim()))));
1172
+
1173
+ let inner = '<div class="hero-content">';
1174
+ if (title) inner += `<h1 class="hero-title hero-title-responsive">${escapeAttr(title)}</h1>`;
1175
+ if (tagline) inner += `<p class="hero-subtitle hero-subtitle-responsive">${escapeAttr(tagline)}</p>`;
1176
+ if (processedBody) inner += `<div class="hero-body">${marked.parse(processedBody)}</div>`;
1177
+ inner += '</div>';
1178
+
1179
+ return `<div class="${classes.join(' ')}"${idAttr}${style}${twinkleAttrs}>${inner}</div>\n`;
1180
+ }
1181
+ );
1182
+ return restore(result);
1183
+ }
1184
+
1185
+ /**
1186
+ * Pre-process [slideover] shortcodes before running through marked.
1187
+ *
1188
+ * Syntax:
1189
+ * [slideover title="More Info" trigger="Read more" size="md" position="right"]
1190
+ * Markdown body here.
1191
+ * [/slideover]
1192
+ *
1193
+ * Outputs a trigger button and a hidden content div with data attributes.
1194
+ * Client-side site.js wires up the click handler to open a Domma slideover.
1195
+ * Attribute values are HTML-escaped to prevent injection.
1196
+ *
1197
+ * @param {string} markdown
1198
+ * @returns {string}
1199
+ */
1200
+ function processSlideoverBlocks(markdown) {
1201
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
1202
+ let counter = 0;
1203
+ const processed = scrubbed.replace(
1204
+ /\[slideover([^\]]*)\]([\s\S]*?)\[\/slideover\]/gi,
1205
+ (_, attrStr, body) => {
1206
+ counter++;
1207
+ const id = `dm-so-${counter}`;
1208
+ const attrs = parseShortcodeAttrs(attrStr);
1209
+ const title = escapeAttr(attrs.title || '');
1210
+ const trigger = escapeAttr(attrs.trigger || 'Open');
1211
+ const size = escapeAttr(attrs.size || 'md');
1212
+ const position = escapeAttr(attrs.position || 'right');
1213
+ const bodyHtml = marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))));
1214
+ return (
1215
+ `<button class="btn btn-link dm-so-trigger" data-so-target="${id}">${trigger}</button>\n` +
1216
+ `<div class="dm-so-content" id="${id}" style="display:none" ` +
1217
+ `data-so-title="${title}" data-so-size="${size}" data-so-position="${position}">${bodyHtml}</div>\n`
1218
+ );
1219
+ }
1220
+ );
1221
+ return restore(processed);
1222
+ }
1223
+
1224
+ /**
1225
+ * Pre-process [dconfig] shortcodes before running through marked.
1226
+ *
1227
+ * Extracts JSON config blocks and base64-encodes them into hidden divs.
1228
+ * Multiple blocks on one page are supported and merged client-side.
1229
+ * Invalid JSON blocks are silently dropped (no render output).
1230
+ *
1231
+ * Syntax:
1232
+ * [dconfig]
1233
+ * { "#btn": { "events": { "click": { "target": "#panel", "toggleClass": "hidden" } } } }
1234
+ * [/dconfig]
1235
+ *
1236
+ * @param {string} markdown
1237
+ * @returns {string}
1238
+ */
1239
+ function processDConfigBlocks(markdown) {
1240
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
1241
+ const processed = scrubbed.replace(
1242
+ /\[dconfig\]([\s\S]*?)\[\/dconfig\]/gi,
1243
+ (_, jsonStr) => {
1244
+ try {
1245
+ JSON.parse(jsonStr.trim()); // validate before encoding
1246
+ const encoded = Buffer.from(jsonStr.trim(), 'utf8').toString('base64');
1247
+ return `<div class="dm-page-config" style="display:none" data-config="${encoded}"></div>\n`;
1248
+ } catch {
1249
+ return ''; // invalid JSON — drop silently
1250
+ }
1251
+ }
1252
+ );
1253
+ return restore(processed);
1254
+ }
1255
+
1256
+ /**
1257
+ * Pre-process [cta] shortcodes into button elements that trigger Actions.
1258
+ *
1259
+ * Wrapping form: [cta action="slug" entry="id" style="primary" icon="check" confirm="Sure?"]Label[/cta]
1260
+ * Self-closing: [cta action="slug" entry="id" label="Run" /]
1261
+ *
1262
+ * Outputs a <button class="btn btn-{style} dm-cta-trigger"> with data-action and data-entry
1263
+ * attributes. Client-side wiring in site.js handles click → POST /api/actions/:slug/public.
1264
+ *
1265
+ * @param {string} markdown
1266
+ * @returns {string}
1267
+ */
1268
+ function processCtaBlocks(markdown) {
1269
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
1270
+
1271
+ function buildButton(attrStr, body) {
1272
+ const attrs = parseShortcodeAttrs(attrStr);
1273
+ const action = attrs.action || '';
1274
+ if (!action) return '';
1275
+ const entry = attrs.entry || '';
1276
+
1277
+ const style = attrs.style || 'primary';
1278
+ const icon = attrs.icon || '';
1279
+ const size = attrs.size || '';
1280
+ const confirm = attrs.confirm || '';
1281
+ const label = body ? body.trim() : (attrs.label || 'Run');
1282
+
1283
+ let cls = `btn btn-${escapeAttr(style)} dm-cta-trigger`;
1284
+ if (size) cls += ` btn-${escapeAttr(size)}`;
1285
+
1286
+ let dataAttrs = `data-action="${escapeAttr(action)}" data-entry="${escapeAttr(entry)}"`;
1287
+ if (confirm) dataAttrs += ` data-confirm="${escapeAttr(confirm)}"`;
1288
+
1289
+ const iconHtml = icon ? `<span data-icon="${escapeAttr(icon)}"></span> ` : '';
1290
+ return `<button class="${cls}" ${dataAttrs}>${iconHtml}${escapeHtmlText(label)}</button>`;
1291
+ }
1292
+
1293
+ // Self-closing: [cta attrs /]
1294
+ let processed = scrubbed.replace(/\[cta([^\]]*?)\/\]/gi, (_, attrStr) => buildButton(attrStr, null));
1295
+ // Wrapping: [cta attrs]body[/cta]
1296
+ processed = processed.replace(/\[cta([^\]]*?)\]([\s\S]*?)\[\/cta\]/gi, (_, attrStr, body) => buildButton(attrStr, body));
1297
+
1298
+ return restore(processed);
1299
+ }
1300
+
1301
+ /**
1302
+ * Parse a Markdown file string into frontmatter data and rendered HTML.
1303
+ *
1304
+ * @param {string} raw - Raw file content (frontmatter + Markdown body)
1305
+ * @returns {{ data: object, content: string, html: string }}
1306
+ */
1307
+ export async function parseMarkdown(raw) {
1308
+ const {data, content} = matter(raw);
1309
+ const extensions = getSanitizeExtensions();
1310
+
1311
+ // Pipeline:
1312
+ // beforeParse → collection → view → dconfig → plugin shortcodes → tabs → accordion → carousel
1313
+ // → countdown → timeline → spacer → center → icon → form → hero → table → badge → cta → grid → card
1314
+ // → slideover → marked → sanitize → afterParse
1315
+ const preprocessed = applyTransforms('markdown:beforeParse', content);
1316
+ const withCollection = await processCollectionBlocks(preprocessed);
1317
+ const withView = await processViewBlocks(withCollection);
1318
+ const withDconfig = processDConfigBlocks(withView);
1319
+ const withPluginShortcodes = processPluginShortcodes(withDconfig);
1320
+ const withTabs = processTabsBlocks(withPluginShortcodes);
1321
+ const withAccordion = processAccordionBlocks(withTabs);
1322
+ const withCarousel = processCarouselBlocks(withAccordion);
1323
+ const withCountdown = processCountdownBlocks(withCarousel);
1324
+ const withTimeline = processTimelineBlocks(withCountdown);
1325
+ const withSpacer = processSpacerBlocks(withTimeline);
1326
+ const withCenter = processCenterBlocks(withSpacer);
1327
+ const withIcon = processIconBlocks(withCenter);
1328
+ const withForm = await processFormBlocks(withIcon);
1329
+ const withHero = processHeroBlocks(withForm);
1330
+ const withTable = processTableBlocks(withHero);
1331
+ const withBadge = processBadgeBlocks(withTable);
1332
+ const withCta = processCtaBlocks(withBadge);
1333
+ const withGrid = processGridBlocks(withCta);
1334
+ const withCard = processCardBlocks(withGrid);
1335
+ const withSlideover = processSlideoverBlocks(withCard);
1336
+ const rendered = marked.parse(withSlideover);
1337
+
1338
+ const sanitized = sanitizeHtml(rendered, {
1339
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat([
1340
+ 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
1341
+ 'form', 'input', 'textarea', 'select', 'option', 'optgroup',
1342
+ 'button', 'label', 'fieldset', 'legend',
1343
+ ...extensions.tags
1344
+ ]),
1345
+ allowedAttributes: {
1346
+ ...sanitizeHtml.defaults.allowedAttributes,
1347
+ '*': ['class', 'id', 'style', 'data-*'],
1348
+ img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
1349
+ form: ['action', 'method'],
1350
+ input: ['type', 'name', 'placeholder', 'value', 'required', 'disabled',
1351
+ 'readonly', 'min', 'max', 'step', 'pattern', 'maxlength',
1352
+ 'minlength', 'checked', 'autocomplete'],
1353
+ textarea: ['name', 'placeholder', 'rows', 'cols', 'required',
1354
+ 'disabled', 'readonly', 'maxlength'],
1355
+ select: ['name', 'required', 'disabled', 'multiple'],
1356
+ option: ['value', 'selected', 'disabled'],
1357
+ optgroup: ['label', 'disabled'],
1358
+ button: ['type', 'disabled', 'data-action', 'data-entry', 'data-confirm'],
1359
+ label: ['for'],
1360
+ fieldset: ['disabled'],
1361
+ ...extensions.attributes
1362
+ }
1363
+ });
1364
+
1365
+ const html = applyTransforms('markdown:afterParse', sanitized);
1366
+ return {data, content, html};
1367
+ }
1368
+
1369
+ /**
1370
+ * Serialise a page object back to a Markdown file string.
1371
+ *
1372
+ * @param {object} frontmatter - Page metadata fields
1373
+ * @param {string} body - Markdown body
1374
+ * @returns {string}
1375
+ */
1376
+ export function serialiseMarkdown(frontmatter, body) {
1377
+ return matter.stringify(body || '', frontmatter);
1378
+ }