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.
- package/README.md +3 -3
- package/admin/css/admin.css +1 -1200
- package/admin/dist/domma/domma-tools.css +2313 -0
- package/admin/dist/domma/domma-tools.min.js +10 -0
- package/admin/index.html +4 -0
- package/admin/js/api.js +1 -242
- package/admin/js/app.js +9 -279
- package/admin/js/config/sidebar-config.js +1 -115
- package/admin/js/lib/card.js +1 -63
- package/admin/js/lib/image-editor.js +1 -869
- package/admin/js/lib/markdown-toolbar.js +54 -421
- package/admin/js/templates/action-editor.html +171 -0
- package/admin/js/templates/actions-list.html +19 -0
- package/admin/js/templates/api-reference.html +1411 -0
- package/admin/js/templates/block-editor.html +158 -0
- package/admin/js/templates/blocks.html +8 -0
- package/admin/js/templates/collection-editor.html +47 -0
- package/admin/js/templates/collection-entries.html +3 -0
- package/admin/js/templates/collections.html +51 -4
- package/admin/js/templates/documentation.html +258 -0
- package/admin/js/templates/form-editor.html +238 -0
- package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
- package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
- package/admin/js/templates/layouts.html +44 -7
- package/admin/js/templates/login.html +29 -4
- package/admin/js/templates/my-profile.html +17 -0
- package/admin/js/templates/page-editor.html +48 -0
- package/admin/js/templates/pages.html +6 -1
- package/admin/js/templates/pro-docs.html +259 -0
- package/admin/js/templates/role-editor.html +59 -0
- package/admin/js/templates/roles.html +10 -0
- package/admin/js/templates/settings.html +137 -18
- package/admin/js/templates/tutorials.html +81 -0
- package/admin/js/templates/user-editor.html +7 -0
- package/admin/js/templates/users.html +3 -1
- package/admin/js/templates/view-editor.html +201 -0
- package/admin/js/templates/view-preview.html +51 -0
- package/admin/js/templates/views-list.html +19 -0
- package/admin/js/views/action-editor.js +1 -0
- package/admin/js/views/actions-list.js +1 -0
- package/admin/js/views/api-reference.js +1 -0
- package/admin/js/views/block-editor.js +8 -0
- package/admin/js/views/blocks.js +4 -0
- package/admin/js/views/collection-editor.js +3 -487
- package/admin/js/views/collection-entries.js +1 -484
- package/admin/js/views/collections.js +1 -153
- package/admin/js/views/dashboard.js +1 -56
- package/admin/js/views/documentation.js +1 -12
- package/admin/js/views/form-editor.js +8 -0
- package/admin/js/views/form-submissions.js +1 -0
- package/admin/js/views/forms.js +1 -0
- package/admin/js/views/index.js +1 -39
- package/admin/js/views/layouts.js +9 -42
- package/admin/js/views/login.js +7 -251
- package/admin/js/views/media.js +1 -240
- package/admin/js/views/my-profile.js +1 -0
- package/admin/js/views/navigation.js +14 -212
- package/admin/js/views/page-editor.js +72 -661
- package/admin/js/views/pages.js +5 -72
- package/admin/js/views/plugins.js +13 -90
- package/admin/js/views/pro-docs.js +1 -0
- package/admin/js/views/role-editor.js +1 -0
- package/admin/js/views/roles.js +4 -0
- package/admin/js/views/settings.js +3 -199
- package/admin/js/views/tutorials.js +1 -12
- package/admin/js/views/user-editor.js +1 -88
- package/admin/js/views/users.js +4 -76
- package/admin/js/views/view-editor.js +1 -0
- package/admin/js/views/view-preview.js +1 -0
- package/admin/js/views/views-list.js +1 -0
- package/bin/cli.js +1 -1
- package/config/auth.json +2 -17
- package/config/connections.json.bak +9 -0
- package/config/connections.json.example +9 -0
- package/config/navigation.json +15 -0
- package/config/plugins.json +19 -29
- package/config/server.json +6 -6
- package/config/site.json +17 -6
- package/package.json +24 -10
- package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
- package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
- package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
- package/plugins/domma-effects/public/celebrations/index.js +1 -535
- package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
- package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
- package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
- package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
- package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
- package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
- package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
- package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
- package/plugins/example-analytics/stats.json +21 -12
- package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/plugin.js +233 -0
- package/plugins/theme-roller/plugin.json +31 -0
- package/plugins/theme-roller/public/active-theme.css +0 -0
- package/plugins/theme-roller/public/inject-head-late.html +1 -0
- package/public/css/forms.css +1 -0
- package/public/css/site.css +1 -302
- package/public/js/btt.js +1 -90
- package/public/js/cookie-consent.js +1 -61
- package/public/js/form-logic-engine.js +1 -0
- package/public/js/forms.js +1 -0
- package/public/js/site.js +1 -204
- package/scripts/build.js +194 -129
- package/scripts/pro.js +254 -0
- package/scripts/reset.js +33 -8
- package/scripts/seed.js +343 -78
- package/scripts/setup.js +5 -4
- package/server/middleware/auth.js +136 -97
- package/server/routes/api/actions.js +200 -0
- package/server/routes/api/auth.js +292 -116
- package/server/routes/api/blocks.js +84 -0
- package/server/routes/api/collections.js +88 -23
- package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +483 -505
- package/server/routes/api/layouts.js +49 -25
- package/server/routes/api/media.js +118 -93
- package/server/routes/api/navigation.js +40 -37
- package/server/routes/api/pages.js +132 -118
- package/server/routes/api/plugins.js +6 -3
- package/server/routes/api/settings.js +104 -89
- package/server/routes/api/users.js +27 -21
- package/server/routes/api/views.js +148 -0
- package/server/routes/public.js +124 -108
- package/server/server.js +269 -173
- package/server/services/actions.js +387 -0
- package/server/services/adapterRegistry.js +98 -0
- package/server/services/adapters/FileAdapter.js +192 -0
- package/server/services/adapters/MongoAdapter.js +220 -0
- package/server/services/blocks.js +162 -0
- package/server/services/collections.js +74 -86
- package/server/services/connectionManager.js +102 -0
- package/server/services/content.js +312 -307
- package/{plugins/form-builder → server/services}/email.js +126 -103
- package/server/services/forms.js +173 -0
- package/server/services/markdown.js +1378 -648
- package/server/services/permissionRegistry.js +173 -0
- package/server/services/presetCollections.js +251 -0
- package/server/services/renderer.js +75 -1
- package/server/services/roles.js +227 -0
- package/server/services/rowAccess.js +104 -0
- package/server/services/userProfiles.js +199 -0
- package/server/services/users.js +281 -212
- package/server/services/views.js +280 -0
- package/server/templates/page.html +119 -113
- package/plugins/form-builder/admin/templates/form-editor.html +0 -171
- package/plugins/form-builder/admin/templates/form-settings.html +0 -29
- package/plugins/form-builder/admin/views/form-editor.js +0 -1442
- package/plugins/form-builder/admin/views/form-settings.js +0 -38
- package/plugins/form-builder/admin/views/form-submissions.js +0 -295
- package/plugins/form-builder/admin/views/forms-list.js +0 -164
- package/plugins/form-builder/config.js +0 -9
- package/plugins/form-builder/data/forms/consent.json +0 -104
- package/plugins/form-builder/data/forms/contact-details.json +0 -63
- package/plugins/form-builder/data/forms/contacts.json +0 -66
- package/plugins/form-builder/data/submissions/consent.json +0 -13
- package/plugins/form-builder/data/submissions/contact-details.json +0 -1
- package/plugins/form-builder/data/submissions/contacts.json +0 -26
- package/plugins/form-builder/plugin.json +0 -52
- package/plugins/form-builder/public/form-logic-engine.js +0 -568
- package/plugins/form-builder/public/inject-body.html +0 -352
- package/plugins/form-builder/public/inject-head.html +0 -58
- package/plugins/form-builder/public/package.json +0 -1
- 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 {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* @param {string} markdown
|
|
270
|
-
* @returns {string}
|
|
271
|
-
*/
|
|
272
|
-
function
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
/\[
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Pre-process [
|
|
512
|
-
*
|
|
513
|
-
* Syntax:
|
|
514
|
-
* [
|
|
515
|
-
*
|
|
516
|
-
* [/
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
* @param {string} markdown
|
|
523
|
-
* @returns {string}
|
|
524
|
-
*/
|
|
525
|
-
function
|
|
526
|
-
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
);
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
...
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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, '&')
|
|
31
|
+
.replace(/</g, '<')
|
|
32
|
+
.replace(/>/g, '>')
|
|
33
|
+
.replace(/"/g, '"');
|
|
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 || ' '}</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 “${escapeHtmlText(blockName)}” 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 “${escapeHtmlText(blockName)}” 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, '&')
|
|
505
|
+
.replace(/"/g, '"')
|
|
506
|
+
.replace(/</g, '<')
|
|
507
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|