domma-cms 0.2.0 → 0.3.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 (72) hide show
  1. package/README.md +2 -3
  2. package/admin/css/admin.css +1 -1200
  3. package/admin/js/api.js +1 -242
  4. package/admin/js/app.js +5 -279
  5. package/admin/js/config/sidebar-config.js +1 -115
  6. package/admin/js/lib/card.js +1 -63
  7. package/admin/js/lib/image-editor.js +1 -869
  8. package/admin/js/lib/markdown-toolbar.js +46 -421
  9. package/admin/js/templates/layouts.html +44 -7
  10. package/admin/js/templates/page-editor.html +9 -0
  11. package/admin/js/templates/settings.html +18 -1
  12. package/admin/js/templates/users.html +29 -4
  13. package/admin/js/views/collection-editor.js +3 -487
  14. package/admin/js/views/collection-entries.js +1 -484
  15. package/admin/js/views/collections.js +1 -153
  16. package/admin/js/views/dashboard.js +1 -56
  17. package/admin/js/views/documentation.js +1 -12
  18. package/admin/js/views/index.js +1 -39
  19. package/admin/js/views/layouts.js +9 -42
  20. package/admin/js/views/login.js +7 -251
  21. package/admin/js/views/media.js +1 -240
  22. package/admin/js/views/navigation.js +14 -212
  23. package/admin/js/views/page-editor.js +53 -661
  24. package/admin/js/views/pages.js +5 -72
  25. package/admin/js/views/plugins.js +13 -90
  26. package/admin/js/views/settings.js +1 -199
  27. package/admin/js/views/tutorials.js +1 -12
  28. package/admin/js/views/user-editor.js +1 -88
  29. package/admin/js/views/users.js +7 -76
  30. package/bin/cli.js +18 -9
  31. package/config/auth.json +1 -17
  32. package/config/navigation.json +15 -0
  33. package/config/site.json +5 -4
  34. package/package.json +1 -1
  35. package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
  36. package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
  37. package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
  38. package/plugins/domma-effects/public/celebrations/index.js +1 -535
  39. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
  40. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
  41. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
  42. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
  43. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
  44. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
  45. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
  46. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
  47. package/plugins/example-analytics/stats.json +16 -12
  48. package/plugins/form-builder/admin/templates/form-editor.html +158 -130
  49. package/plugins/form-builder/admin/views/form-editor.js +3 -1
  50. package/plugins/form-builder/data/forms/contact-details.json +71 -35
  51. package/plugins/form-builder/data/forms/feedback.json +130 -0
  52. package/plugins/form-builder/data/submissions/feedback.json +1 -0
  53. package/plugins/form-builder/public/form-logic-engine.js +1 -568
  54. package/public/css/site.css +1 -302
  55. package/public/js/btt.js +1 -90
  56. package/public/js/cookie-consent.js +1 -61
  57. package/public/js/site.js +1 -204
  58. package/scripts/setup.js +12 -9
  59. package/server/middleware/auth.js +44 -21
  60. package/server/routes/api/auth.js +38 -8
  61. package/server/routes/api/collections.js +18 -5
  62. package/server/routes/api/layouts.js +18 -4
  63. package/server/routes/api/media.js +2 -3
  64. package/server/routes/api/navigation.js +2 -3
  65. package/server/routes/api/pages.js +3 -3
  66. package/server/routes/api/settings.js +2 -3
  67. package/server/routes/api/users.js +4 -6
  68. package/server/routes/public.js +3 -3
  69. package/server/server.js +8 -0
  70. package/server/services/markdown.js +102 -3
  71. package/server/services/userTypes.js +167 -0
  72. package/plugins/form-builder/email.js +0 -103
package/server/server.js CHANGED
@@ -15,6 +15,7 @@ import {fileURLToPath} from 'url';
15
15
  import {createRequire} from 'module';
16
16
  import {config} from './config.js';
17
17
  import {registerPlugins} from './services/plugins.js';
18
+ import { seed as seedUserTypes, load as loadUserTypes } from './services/userTypes.js';
18
19
 
19
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
21
  const ROOT = path.resolve(__dirname, '..');
@@ -84,6 +85,13 @@ await fs.mkdir(usersDir, { recursive: true });
84
85
  await fs.mkdir(collectionsDir, { recursive: true });
85
86
  await fs.mkdir(pluginsDir, { recursive: true });
86
87
 
88
+ // ---------------------------------------------------------------------------
89
+ // User Types — seed preset collection + load into cache
90
+ // ---------------------------------------------------------------------------
91
+
92
+ await seedUserTypes();
93
+ await loadUserTypes();
94
+
87
95
  // Serve uploaded media files
88
96
  await app.register(staticPlugin, {
89
97
  root: mediaDir,
@@ -6,6 +6,7 @@ import matter from 'gray-matter';
6
6
  import {marked} from 'marked';
7
7
  import sanitizeHtml from 'sanitize-html';
8
8
  import {applyTransforms, getSanitizeExtensions, getShortcodeProcessors} from './hooks.js';
9
+ import {getConfig} from '../config.js';
9
10
 
10
11
  // Configure marked for safe output
11
12
  marked.setOptions({
@@ -136,6 +137,7 @@ function processGridBlocks(markdown) {
136
137
  if (attrs.cols) classes.push(`grid-cols-${attrs.cols}`);
137
138
  if (attrs.gap) classes.push(`gap-${attrs.gap}`);
138
139
  if (attrs.class) classes.push(attrs.class);
140
+ if (attrs.fullwidth === 'true') classes.push('grid-breakout');
139
141
  const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
140
142
  return `<div class="${classes.join(' ')}"${id}>${inner}</div>\n`;
141
143
  }
@@ -466,6 +468,96 @@ function processTableBlocks(markdown) {
466
468
  * @param {string} markdown
467
469
  * @returns {string}
468
470
  */
471
+ /**
472
+ * Pre-process [spacer] self-closing shortcode.
473
+ *
474
+ * Syntax:
475
+ * [spacer /] - uses defaults from layoutOptions config
476
+ * [spacer size="24" /] - explicit pixel height
477
+ * [spacer class="my-gap" /] - extra CSS class
478
+ * [spacer size="16" class="mt-4" /] - both
479
+ *
480
+ * @param {string} markdown
481
+ * @returns {string}
482
+ */
483
+ function processSpacerBlocks(markdown) {
484
+ const opts = getConfig('site')?.layoutOptions ?? {};
485
+ const defaultSize = opts.spacerSize ?? 8;
486
+ const defaultClass = opts.spacerClass ?? '';
487
+ return markdown.replace(
488
+ /\[spacer([^\]]*?)\/?\]/gi,
489
+ (_, attrStr) => {
490
+ const attrs = parseShortcodeAttrs(attrStr || '');
491
+ const size = parseInt(attrs.size, 10) || defaultSize;
492
+ const extra = attrs.class || defaultClass;
493
+ const classes = ['dm-spacer', extra].filter(Boolean).join(' ');
494
+ return `<div class="${classes}" style="height:${size}px" aria-hidden="true"></div>\n`;
495
+ }
496
+ );
497
+ }
498
+
499
+ /**
500
+ * Pre-process [icon] self-closing shortcodes before running through marked.
501
+ *
502
+ * Syntax:
503
+ * [icon name="arrow-right" /]
504
+ * [icon name="star" size="24" color="#f59e0b" class="my-icon" /]
505
+ *
506
+ * Supported attributes:
507
+ * name - Domma icon name (required). Also accepted as src= for convenience.
508
+ * size - Width and height in px (default: 1em via CSS)
509
+ * color - CSS colour applied via style
510
+ * class - Extra CSS classes
511
+ *
512
+ * @param {string} markdown
513
+ * @returns {string}
514
+ */
515
+ function processIconBlocks(markdown) {
516
+ return markdown.replace(
517
+ /\[icon([^\]]*?)\/\]/gi,
518
+ (_, attrStr) => {
519
+ const attrs = parseShortcodeAttrs(attrStr);
520
+ const name = attrs.name || attrs.src || '';
521
+ if (!name) return '';
522
+ const classes = ['dm-icon', attrs.class].filter(Boolean).join(' ');
523
+ const sizeAttr = attrs.size ? ` data-icon-size="${parseInt(attrs.size, 10)}"` : '';
524
+ const colorAttr = attrs.color ? ` data-icon-colour="${escapeAttr(attrs.color)}"` : '';
525
+ return `<span data-icon="${escapeAttr(name)}" class="${escapeAttr(classes)}"${sizeAttr}${colorAttr}></span>`;
526
+ }
527
+ );
528
+ }
529
+
530
+ /**
531
+ * Pre-process [form] self-closing shortcodes before running through marked.
532
+ *
533
+ * Syntax:
534
+ * [form name="contact" /]
535
+ * [form name="newsletter" class="my-form" /]
536
+ *
537
+ * Supported attributes:
538
+ * name - Form slug as configured in the form-builder (required). Also accepted as slug=.
539
+ * class - Extra CSS classes on the wrapper div
540
+ * id - Element id attribute
541
+ *
542
+ * The form-builder's injected client script scans for [data-form] and renders the form.
543
+ *
544
+ * @param {string} markdown
545
+ * @returns {string}
546
+ */
547
+ function processFormBlocks(markdown) {
548
+ return markdown.replace(
549
+ /\[form([^\]]*?)\/\]/gi,
550
+ (_, attrStr) => {
551
+ const attrs = parseShortcodeAttrs(attrStr);
552
+ const name = attrs.name || attrs.slug || '';
553
+ if (!name) return '';
554
+ const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
555
+ const classAttr = attrs.class ? ` class="${escapeAttr(attrs.class)}"` : '';
556
+ return `<div data-form="${escapeAttr(name)}"${idAttr}${classAttr}></div>`;
557
+ }
558
+ );
559
+ }
560
+
469
561
  function processHeroBlocks(markdown) {
470
562
  return markdown.replace(
471
563
  /\[hero([^\]]*)\]([\s\S]*?)\[\/hero\]/gi,
@@ -481,6 +573,7 @@ function processHeroBlocks(markdown) {
481
573
  const fullwidth = attrs.fullwidth === 'true';
482
574
  const cls = attrs.class || '';
483
575
  const id = attrs.id || '';
576
+ const bg = attrs.bg || '';
484
577
 
485
578
  const classes = ['hero'];
486
579
  if (size) classes.push(`hero-${size}`);
@@ -491,7 +584,10 @@ function processHeroBlocks(markdown) {
491
584
  if (fullwidth) classes.push('hero-breakout');
492
585
  if (cls) classes.push(cls);
493
586
 
494
- const style = image ? ` style="background-image:url('${escapeAttr(image)}')"` : '';
587
+ const styleParts = [];
588
+ if (image) styleParts.push(`background-image:url('${escapeAttr(image)}')`);
589
+ if (bg) styleParts.push(`background-color:${escapeAttr(bg)}`);
590
+ const style = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
495
591
  const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
496
592
 
497
593
  const processedBody = processCardBlocks(processGridBlocks(body.trim()));
@@ -590,7 +686,7 @@ export function parseMarkdown(raw) {
590
686
 
591
687
  // Pipeline:
592
688
  // beforeParse → dconfig → plugin shortcodes → tabs → accordion → carousel → countdown
593
- // → hero → grid → card → slideover → marked → sanitize → afterParse
689
+ // → spacer → icon → form → hero → grid → card → slideover → marked → sanitize → afterParse
594
690
  const preprocessed = applyTransforms('markdown:beforeParse', content);
595
691
  const withDconfig = processDConfigBlocks(preprocessed);
596
692
  const withPluginShortcodes = processPluginShortcodes(withDconfig);
@@ -598,7 +694,10 @@ export function parseMarkdown(raw) {
598
694
  const withAccordion = processAccordionBlocks(withTabs);
599
695
  const withCarousel = processCarouselBlocks(withAccordion);
600
696
  const withCountdown = processCountdownBlocks(withCarousel);
601
- const withHero = processHeroBlocks(withCountdown);
697
+ const withSpacer = processSpacerBlocks(withCountdown);
698
+ const withIcon = processIconBlocks(withSpacer);
699
+ const withForm = processFormBlocks(withIcon);
700
+ const withHero = processHeroBlocks(withForm);
602
701
  const withTable = processTableBlocks(withHero);
603
702
  const withGrid = processGridBlocks(withTable);
604
703
  const withCard = processCardBlocks(withGrid);
@@ -0,0 +1,167 @@
1
+ /**
2
+ * User Types Service
3
+ * Preset Collection — seeds roles on first startup, caches them in memory.
4
+ * Auth middleware calls getRoleMap() / getPermissionsFor() at request time.
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+ import { config } from '../config.js';
10
+
11
+ const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
12
+ const SLUG = 'user-types';
13
+ const DIR = path.join(COLLECTIONS_DIR, SLUG);
14
+ const SCHEMA_PATH = path.join(DIR, 'schema.json');
15
+ const DATA_PATH = path.join(DIR, 'data.json');
16
+
17
+ const RESOURCES = ['pages', 'settings', 'navigation', 'layouts', 'media', 'users', 'plugins', 'collections'];
18
+
19
+ const PRESET_SCHEMA = {
20
+ slug: SLUG,
21
+ title: 'User Types',
22
+ description: 'CMS role definitions — managed by the system.',
23
+ preset: true,
24
+ fields: [
25
+ { name: 'name', label: 'Name (slug)', type: 'text', required: true },
26
+ { name: 'label', label: 'Label', type: 'text', required: true },
27
+ { name: 'level', label: 'Level', type: 'number', required: true },
28
+ { name: 'permissions', label: 'Permissions', type: 'multi-select', options: RESOURCES },
29
+ { name: 'badgeClass', label: 'Badge Class', type: 'select',
30
+ options: ['badge-danger','badge-warning','badge-info','badge-secondary','badge-success','badge-primary'] }
31
+ ],
32
+ api: {
33
+ create: { enabled: false, access: 'admin' },
34
+ read: { enabled: false, access: 'admin' },
35
+ update: { enabled: false, access: 'admin' },
36
+ delete: { enabled: false, access: 'admin' }
37
+ }
38
+ };
39
+
40
+ const SEED_ENTRIES = [
41
+ { name: 'admin', label: 'Admin', level: 0, permissions: RESOURCES, badgeClass: 'badge-danger' },
42
+ { name: 'manager', label: 'Manager', level: 1, permissions: ['pages','settings','navigation','layouts','media','users','collections'], badgeClass: 'badge-warning' },
43
+ { name: 'editor', label: 'Editor', level: 2, permissions: ['pages','media'], badgeClass: 'badge-info' },
44
+ { name: 'subscriber', label: 'Subscriber', level: 3, permissions: [], badgeClass: 'badge-secondary' }
45
+ ];
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // In-memory cache
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** @type {Map<string,{label:string,level:number,badgeClass:string}>} */
52
+ let roleMap = new Map();
53
+
54
+ /** @type {Map<string,string[]>} */
55
+ let permissionsMap = new Map();
56
+
57
+ /**
58
+ * Build in-memory maps from an array of data entries.
59
+ *
60
+ * @param {object[]} entries
61
+ */
62
+ function buildCache(entries) {
63
+ roleMap = new Map();
64
+ permissionsMap = new Map();
65
+
66
+ for (const entry of entries) {
67
+ const d = entry.data;
68
+ roleMap.set(d.name, { label: d.label, level: d.level, badgeClass: d.badgeClass || '' });
69
+ for (const resource of (d.permissions || [])) {
70
+ if (!permissionsMap.has(resource)) permissionsMap.set(resource, []);
71
+ permissionsMap.get(resource).push(d.name);
72
+ }
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Public API
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Seed the preset collection on first startup (no-op if data.json already exists).
82
+ *
83
+ * @returns {Promise<void>}
84
+ */
85
+ export async function seed() {
86
+ await fs.mkdir(DIR, { recursive: true });
87
+
88
+ // Always write schema (overwrite to keep in sync with code)
89
+ await fs.writeFile(SCHEMA_PATH, JSON.stringify(PRESET_SCHEMA, null, 2) + '\n', 'utf8');
90
+
91
+ // Only write data if it doesn't exist yet
92
+ try {
93
+ await fs.access(DATA_PATH);
94
+ } catch {
95
+ const entries = SEED_ENTRIES.map(data => ({
96
+ id: uuidv4(),
97
+ data,
98
+ createdAt: new Date().toISOString(),
99
+ updatedAt: new Date().toISOString()
100
+ }));
101
+ await fs.writeFile(DATA_PATH, JSON.stringify(entries, null, 2) + '\n', 'utf8');
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Load the collection from disk into the in-memory cache.
107
+ *
108
+ * @returns {Promise<void>}
109
+ */
110
+ export async function load() {
111
+ try {
112
+ const raw = await fs.readFile(DATA_PATH, 'utf8');
113
+ const entries = JSON.parse(raw);
114
+ buildCache(entries);
115
+ } catch (err) {
116
+ console.warn('[userTypes] Failed to load user-types collection:', err.message);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Reload from disk — call after any CRUD on the user-types collection.
122
+ *
123
+ * @returns {Promise<void>}
124
+ */
125
+ export async function invalidate() {
126
+ await load();
127
+ }
128
+
129
+ /**
130
+ * Return the full role map.
131
+ *
132
+ * @returns {Map<string,{label:string,level:number,badgeClass:string}>}
133
+ */
134
+ export function getRoleMap() {
135
+ return roleMap;
136
+ }
137
+
138
+ /**
139
+ * Return the level for a named role, or Infinity if not found.
140
+ *
141
+ * @param {string} roleName
142
+ * @returns {number}
143
+ */
144
+ export function getRoleLevel(roleName) {
145
+ return roleMap.get(roleName)?.level ?? Infinity;
146
+ }
147
+
148
+ /**
149
+ * Return the role names allowed to access a resource, or [] if resource unknown.
150
+ *
151
+ * @param {string} resource
152
+ * @returns {string[]}
153
+ */
154
+ export function getPermissionsFor(resource) {
155
+ return permissionsMap.get(resource) ?? [];
156
+ }
157
+
158
+ /**
159
+ * Return role names ordered from most to least privileged.
160
+ *
161
+ * @returns {string[]}
162
+ */
163
+ export function getRoleHierarchy() {
164
+ return [...roleMap.entries()]
165
+ .sort((a, b) => a[1].level - b[1].level)
166
+ .map(([key]) => key);
167
+ }
@@ -1,103 +0,0 @@
1
- /**
2
- * Form Builder Plugin — Email Helper
3
- * Nodemailer transport factory and generic form email sending utility.
4
- */
5
- import nodemailer from 'nodemailer';
6
-
7
- /**
8
- * Escape HTML special characters for safe use in email bodies.
9
- *
10
- * @param {string} str
11
- * @returns {string}
12
- */
13
- export function escapeHtml(str) {
14
- return String(str)
15
- .replace(/&/g, '&amp;')
16
- .replace(/</g, '&lt;')
17
- .replace(/>/g, '&gt;')
18
- .replace(/"/g, '&quot;')
19
- .replace(/'/g, '&#39;');
20
- }
21
-
22
- /**
23
- * Create a nodemailer transport.
24
- * Falls back to an Ethereal test account when no SMTP host is configured.
25
- *
26
- * @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
27
- * @returns {Promise<import('nodemailer').Transporter>}
28
- * @throws {Error} If Ethereal test account creation fails.
29
- */
30
- export async function createTransport(smtp) {
31
- if (smtp?.host) {
32
- return nodemailer.createTransport({
33
- host: smtp.host,
34
- port: smtp.port || 587,
35
- secure: smtp.secure || false,
36
- auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined
37
- });
38
- }
39
-
40
- // No SMTP configured — use Ethereal for dev/demo
41
- const testAccount = await nodemailer.createTestAccount();
42
- console.log('[form-builder] No SMTP configured. Using Ethereal test account:', testAccount.user);
43
- return nodemailer.createTransport({
44
- host: 'smtp.ethereal.email',
45
- port: 587,
46
- secure: false,
47
- auth: { user: testAccount.user, pass: testAccount.pass }
48
- });
49
- }
50
-
51
- /**
52
- * Send an HTML + plain-text form submission notification email.
53
- * Builds a generic table of field→value pairs from the submitted data.
54
- *
55
- * @param {import('nodemailer').Transporter} transport
56
- * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
57
- * @returns {Promise<void>}
58
- * @throws {Error} If sending the email fails.
59
- */
60
- export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
61
- const rows = fields.map(field => {
62
- const val = data[field.name] ?? '';
63
- const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
64
- const safeLabel = escapeHtml(field.label || field.name);
65
- return `
66
- <tr>
67
- <td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
68
- <td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
69
- </tr>`.trim();
70
- }).join('\n');
71
-
72
- const plainRows = fields.map(field => {
73
- const val = data[field.name] ?? '';
74
- return `${field.label || field.name}: ${val}`;
75
- }).join('\n');
76
-
77
- const html = `
78
- <!DOCTYPE html>
79
- <html>
80
- <body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
81
- <h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
82
- <p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
83
- <table style="width:100%;border-collapse:collapse;margin-top:16px;">
84
- ${rows}
85
- </table>
86
- </body>
87
- </html>`.trim();
88
-
89
- const text = `New form submission: ${formTitle}\n\n${plainRows}`;
90
-
91
- const info = await transport.sendMail({
92
- from: `"${fromName}" <${from}>`,
93
- to,
94
- subject,
95
- text,
96
- html
97
- });
98
-
99
- const previewUrl = nodemailer.getTestMessageUrl(info);
100
- if (previewUrl) {
101
- console.log('[form-builder] Email preview URL:', previewUrl);
102
- }
103
- }