domma-cms 0.44.0 → 0.45.1

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 (254) hide show
  1. package/CLAUDE.md +53 -53
  2. package/README.md +27 -27
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/index.html +2 -2
  7. package/admin/js/app.js +1 -1
  8. package/admin/js/lib/card-builder.js +2 -2
  9. package/admin/js/lib/crud-tutorial.js +1 -1
  10. package/admin/js/lib/effect-defs.js +1 -1
  11. package/admin/js/lib/effects-builder.js +1 -1
  12. package/admin/js/lib/image-editor.js +1 -1
  13. package/admin/js/lib/markdown-toolbar.js +2 -2
  14. package/admin/js/lib/project-context.js +1 -1
  15. package/admin/js/lib/project-quick-create.js +1 -1
  16. package/admin/js/lib/scribe-composer.js +1 -1
  17. package/admin/js/lib/server-restart.js +1 -1
  18. package/admin/js/lib/shortcode-context-menu.js +2 -2
  19. package/admin/js/lib/simple-editor.js +1 -1
  20. package/admin/js/lib/themes.js +1 -1
  21. package/admin/js/lib/timeline-builder.js +1 -1
  22. package/admin/js/templates/action-editor.html +8 -8
  23. package/admin/js/templates/api-endpoint-editor.html +7 -7
  24. package/admin/js/templates/api-endpoints.html +1 -1
  25. package/admin/js/templates/api-reference.html +1 -1
  26. package/admin/js/templates/block-editor.html +6 -6
  27. package/admin/js/templates/collection-editor.html +1 -1
  28. package/admin/js/templates/component-editor.html +2 -2
  29. package/admin/js/templates/dashboard/cache.html +3 -3
  30. package/admin/js/templates/dashboard/journeys.html +3 -3
  31. package/admin/js/templates/dashboard/kpi-strip.html +3 -3
  32. package/admin/js/templates/dashboard/top-pages.html +1 -1
  33. package/admin/js/templates/dashboard/traffic-chart.html +1 -1
  34. package/admin/js/templates/docs/api-actions.html +1 -1
  35. package/admin/js/templates/docs/api-collections.html +4 -4
  36. package/admin/js/templates/docs/api-pages.html +2 -2
  37. package/admin/js/templates/docs/api-plugins.html +1 -1
  38. package/admin/js/templates/docs/api-settings.html +2 -2
  39. package/admin/js/templates/docs/api-users.html +1 -1
  40. package/admin/js/templates/docs/components-howto.html +4 -4
  41. package/admin/js/templates/docs/components-reference.html +21 -21
  42. package/admin/js/templates/docs/components-rules.html +14 -14
  43. package/admin/js/templates/docs/components-walkthrough.html +13 -13
  44. package/admin/js/templates/docs/tutorial-crud.html +49 -49
  45. package/admin/js/templates/docs/tutorial-forms.html +4 -4
  46. package/admin/js/templates/docs/tutorial-plugin.html +10 -10
  47. package/admin/js/templates/docs/usage-actions.html +6 -6
  48. package/admin/js/templates/docs/usage-cta-shortcode.html +9 -9
  49. package/admin/js/templates/docs/usage-dconfig.html +4 -4
  50. package/admin/js/templates/docs/usage-navigation.html +1 -1
  51. package/admin/js/templates/docs/usage-pages.html +1 -1
  52. package/admin/js/templates/docs/usage-shortcodes.html +24 -24
  53. package/admin/js/templates/docs/usage-users-roles.html +3 -3
  54. package/admin/js/templates/docs/usage-views.html +4 -4
  55. package/admin/js/templates/documentation.html +1 -1
  56. package/admin/js/templates/effects.html +752 -752
  57. package/admin/js/templates/form-editor.html +5 -5
  58. package/admin/js/templates/forms.html +17 -17
  59. package/admin/js/templates/login.html +5 -5
  60. package/admin/js/templates/menu-editor.html +10 -10
  61. package/admin/js/templates/my-profile.html +17 -17
  62. package/admin/js/templates/navigation.html +11 -11
  63. package/admin/js/templates/notifications.html +1 -1
  64. package/admin/js/templates/page-editor.html +4 -4
  65. package/admin/js/templates/pro-docs.html +14 -14
  66. package/admin/js/templates/role-editor.html +70 -70
  67. package/admin/js/templates/roles.html +10 -10
  68. package/admin/js/templates/settings.html +10 -10
  69. package/admin/js/templates/tutorials.html +3 -3
  70. package/admin/js/templates/view-editor.html +15 -15
  71. package/admin/js/views/action-editor.js +1 -1
  72. package/admin/js/views/actions-list.js +1 -1
  73. package/admin/js/views/api-endpoint-editor.js +2 -2
  74. package/admin/js/views/api-endpoints.js +2 -2
  75. package/admin/js/views/api-tokens.js +3 -3
  76. package/admin/js/views/blocks.js +2 -2
  77. package/admin/js/views/collection-editor.js +1 -1
  78. package/admin/js/views/collection-entries.js +1 -1
  79. package/admin/js/views/collections.js +1 -1
  80. package/admin/js/views/component-editor.js +1 -1
  81. package/admin/js/views/components.js +4 -4
  82. package/admin/js/views/dashboard/widgets/cache.js +1 -1
  83. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  84. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -1
  85. package/admin/js/views/form-editor.js +7 -7
  86. package/admin/js/views/form-submissions.js +1 -1
  87. package/admin/js/views/forms.js +1 -1
  88. package/admin/js/views/layouts.js +1 -1
  89. package/admin/js/views/login.js +2 -2
  90. package/admin/js/views/menu-editor.js +3 -3
  91. package/admin/js/views/menu-locations.js +1 -1
  92. package/admin/js/views/menus.js +3 -3
  93. package/admin/js/views/navigation.js +8 -8
  94. package/admin/js/views/page-editor.js +40 -40
  95. package/admin/js/views/pages.js +3 -3
  96. package/admin/js/views/plugin-code.js +2 -2
  97. package/admin/js/views/plugins.js +1 -1
  98. package/admin/js/views/project-settings.js +1 -1
  99. package/admin/js/views/projects.js +1 -1
  100. package/admin/js/views/roles.js +1 -1
  101. package/admin/js/views/user-editor.js +1 -1
  102. package/admin/js/views/users.js +3 -3
  103. package/admin/js/views/view-editor.js +1 -1
  104. package/admin/js/views/view-preview.js +1 -1
  105. package/admin/js/views/views-list.js +1 -1
  106. package/bin/cli.js +8 -8
  107. package/bin/lib/config-merge.js +44 -44
  108. package/bin/update.js +16 -16
  109. package/config/plugins.json +1 -1
  110. package/package.json +2 -2
  111. package/plugins/_template/admin/views/index.js +1 -1
  112. package/plugins/_template/config.js +1 -1
  113. package/plugins/_template/plugin.js +1 -1
  114. package/plugins/analytics/admin/templates/analytics.html +3 -3
  115. package/plugins/analytics/admin/views/analytics.js +1 -1
  116. package/plugins/analytics/config.js +1 -1
  117. package/plugins/analytics/plugin.js +4 -4
  118. package/plugins/analytics/plugin.json +1 -1
  119. package/plugins/analytics/public/inject-body.html +2 -2
  120. package/plugins/analytics/public/inject-head.html +1 -1
  121. package/plugins/blog/admin/templates/blog.html +4 -4
  122. package/plugins/blog/admin/views/blog.js +3 -3
  123. package/plugins/blog/admin/views/categories.js +2 -2
  124. package/plugins/blog/admin/views/comments.js +4 -4
  125. package/plugins/blog/admin/views/post-editor.js +1 -1
  126. package/plugins/blog/plugin.js +2 -2
  127. package/plugins/blog/plugin.json +4 -4
  128. package/plugins/contacts/admin/views/contacts.js +8 -8
  129. package/plugins/contacts/plugin.js +4 -4
  130. package/plugins/contacts/plugin.json +1 -1
  131. package/plugins/invoice/admin/templates/editor.html +6 -6
  132. package/plugins/invoice/admin/templates/index.html +4 -4
  133. package/plugins/invoice/admin/views/editor.js +3 -3
  134. package/plugins/invoice/admin/views/index.js +4 -4
  135. package/plugins/invoice/admin/views/issuers.js +1 -1
  136. package/plugins/invoice/admin/views/party-view.js +3 -3
  137. package/plugins/invoice/admin/views/receivers.js +1 -1
  138. package/plugins/invoice/config.js +1 -1
  139. package/plugins/invoice/plugin.js +1 -1
  140. package/plugins/invoice/plugin.json +4 -4
  141. package/plugins/notes/admin/views/notes.js +4 -4
  142. package/plugins/notes/plugin.js +1 -1
  143. package/plugins/notes/plugin.json +1 -1
  144. package/plugins/site-search/admin/templates/site-search.html +3 -3
  145. package/plugins/site-search/admin/views/site-search.js +1 -1
  146. package/plugins/site-search/config.js +1 -1
  147. package/plugins/site-search/plugin.js +4 -4
  148. package/plugins/site-search/plugin.json +1 -1
  149. package/plugins/surveys/admin/templates/results.html +1 -1
  150. package/plugins/surveys/admin/templates/survey-editor.html +4 -4
  151. package/plugins/surveys/admin/views/audience.js +6 -6
  152. package/plugins/surveys/admin/views/results.js +3 -3
  153. package/plugins/surveys/admin/views/survey-editor.js +1 -1
  154. package/plugins/surveys/admin/views/surveys.js +2 -2
  155. package/plugins/surveys/plugin.js +1 -1
  156. package/plugins/surveys/plugin.json +4 -4
  157. package/plugins/theme-switcher/admin/views/theme-switcher.js +1 -1
  158. package/plugins/theme-switcher/plugin.json +3 -3
  159. package/plugins/theme-switcher/public/inject-body.html +28 -28
  160. package/plugins/theme-switcher/public/inject-head.html +1 -1
  161. package/plugins/todo/plugin.js +1 -1
  162. package/public/css/forms.css +1 -1
  163. package/public/js/collection-browser.js +1 -1
  164. package/public/js/collection-context.js +1 -1
  165. package/public/js/form-logic-engine.js +1 -1
  166. package/public/js/forms.js +2 -1
  167. package/public/js/site.js +1 -1
  168. package/scripts/build.js +14 -14
  169. package/scripts/create-plugin.js +2 -2
  170. package/scripts/fresh.js +2 -2
  171. package/scripts/gen-instance-secret.js +1 -1
  172. package/scripts/pro.js +12 -12
  173. package/scripts/reset.js +2 -2
  174. package/scripts/setup.js +15 -15
  175. package/scripts/users.js +5 -5
  176. package/scripts/verify-assets.mjs +6 -6
  177. package/server/config.js +1 -1
  178. package/server/middleware/auth.js +253 -253
  179. package/server/middleware/managerAuth.js +3 -3
  180. package/server/routes/api/actions.js +4 -4
  181. package/server/routes/api/api-endpoints.js +7 -7
  182. package/server/routes/api/api-tokens.js +5 -5
  183. package/server/routes/api/auth.js +309 -309
  184. package/server/routes/api/blocks.js +3 -3
  185. package/server/routes/api/collections.js +18 -18
  186. package/server/routes/api/dashboard.js +5 -5
  187. package/server/routes/api/effects.js +2 -2
  188. package/server/routes/api/endpoints-public.js +5 -5
  189. package/server/routes/api/forms.js +47 -47
  190. package/server/routes/api/menu-locations.js +3 -3
  191. package/server/routes/api/menus.js +7 -7
  192. package/server/routes/api/navigation.js +42 -42
  193. package/server/routes/api/notifications.js +2 -2
  194. package/server/routes/api/pages.js +14 -2
  195. package/server/routes/api/plugin-marketplace.js +3 -3
  196. package/server/routes/api/plugins.js +6 -6
  197. package/server/routes/api/projects.js +9 -9
  198. package/server/routes/api/scaffold.js +7 -7
  199. package/server/routes/api/settings.js +6 -6
  200. package/server/routes/api/sidebar.js +1 -1
  201. package/server/routes/api/users.js +5 -5
  202. package/server/routes/api/versions.js +1 -1
  203. package/server/routes/api/views.js +1 -1
  204. package/server/routes/docs-public.js +3 -3
  205. package/server/routes/public.js +14 -14
  206. package/server/server.js +34 -16
  207. package/server/services/actions.js +19 -19
  208. package/server/services/adapters/FileAdapter.js +2 -2
  209. package/server/services/adapters/MongoAdapter.js +5 -5
  210. package/server/services/apiEndpoints.js +15 -15
  211. package/server/services/apiTokens.js +7 -7
  212. package/server/services/blocks.js +13 -13
  213. package/server/services/cache/drivers/MemoryDriver.js +3 -3
  214. package/server/services/cache/drivers/NoneDriver.js +1 -1
  215. package/server/services/cache/index.js +5 -5
  216. package/server/services/collections.js +14 -14
  217. package/server/services/components.js +9 -9
  218. package/server/services/connectionManager.js +4 -4
  219. package/server/services/content.js +25 -2
  220. package/server/services/docs.js +14 -14
  221. package/server/services/email.js +167 -167
  222. package/server/services/filterEngine.js +6 -6
  223. package/server/services/forms.js +18 -10
  224. package/server/services/health.js +1 -1
  225. package/server/services/hooks.js +7 -7
  226. package/server/services/images.js +4 -4
  227. package/server/services/managerClient.js +1 -1
  228. package/server/services/markdown.js +79 -79
  229. package/server/services/menuRender.js +5 -5
  230. package/server/services/menus-migration.js +3 -3
  231. package/server/services/menus.js +31 -31
  232. package/server/services/permissionRegistry.js +1 -1
  233. package/server/services/pluginFiles.js +5 -5
  234. package/server/services/pluginInstaller.js +17 -17
  235. package/server/services/pluginScaffold.js +1 -1
  236. package/server/services/plugins.js +17 -17
  237. package/server/services/presetCollections.js +2 -2
  238. package/server/services/projects.js +35 -26
  239. package/server/services/recipes/contact-list.json +1 -1
  240. package/server/services/recipes/onboarding.json +8 -8
  241. package/server/services/references.js +8 -8
  242. package/server/services/renderer.js +9 -9
  243. package/server/services/roles.js +9 -9
  244. package/server/services/rowAccess.js +10 -10
  245. package/server/services/scaffolder.js +30 -30
  246. package/server/services/sidebar-migration.js +10 -10
  247. package/server/services/sitemap.js +1 -1
  248. package/server/services/userProfiles.js +199 -199
  249. package/server/services/userRoles.js +7 -7
  250. package/server/services/users.js +302 -302
  251. package/server/services/versions.js +3 -3
  252. package/server/services/viewPipeline.js +6 -6
  253. package/server/services/views.js +11 -11
  254. package/server/templates/page.html +5 -5
@@ -1,167 +1,167 @@
1
- /**
2
- * Core Email Utility
3
- * Nodemailer transport factory and generic form email sending utility.
4
- */
5
- import nodemailer from 'nodemailer';
6
-
7
- let lastSendResult = null;
8
-
9
- /**
10
- * Return the most recent email send result, or null if no send has occurred.
11
- *
12
- * @returns {{ ok: boolean, at: string, info: string|null } | null}
13
- */
14
- export function getLastSendResult() {
15
- return lastSendResult;
16
- }
17
-
18
- /**
19
- * Record the outcome of an email send for health reporting.
20
- *
21
- * @param {boolean} ok
22
- * @param {string|null} info
23
- * @returns {void}
24
- */
25
- function recordSendResult(ok, info) {
26
- lastSendResult = {
27
- ok,
28
- at: new Date().toISOString(),
29
- info: info || null
30
- };
31
- }
32
-
33
- /**
34
- * Escape HTML special characters for safe use in email bodies.
35
- *
36
- * @param {string} str
37
- * @returns {string}
38
- */
39
- export function escapeHtml(str) {
40
- return String(str)
41
- .replace(/&/g, '&')
42
- .replace(/</g, '&lt;')
43
- .replace(/>/g, '&gt;')
44
- .replace(/"/g, '&quot;')
45
- .replace(/'/g, '&#39;');
46
- }
47
-
48
- /**
49
- * Create a nodemailer transport.
50
- * Falls back to an Ethereal test account when no SMTP host is configured.
51
- *
52
- * @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
53
- * @returns {Promise<import('nodemailer').Transporter>}
54
- * @throws {Error} If Ethereal test account creation fails.
55
- */
56
- export async function createTransport(smtp) {
57
- if (smtp?.host) {
58
- return nodemailer.createTransport({
59
- host: smtp.host,
60
- port: smtp.port || 587,
61
- secure: smtp.secure || false,
62
- auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
63
- tls: { rejectUnauthorized: false }
64
- });
65
- }
66
-
67
- // No SMTP configured — use Ethereal for dev/demo
68
- const testAccount = await nodemailer.createTestAccount();
69
- console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
70
- return nodemailer.createTransport({
71
- host: 'smtp.ethereal.email',
72
- port: 587,
73
- secure: false,
74
- auth: { user: testAccount.user, pass: testAccount.pass }
75
- });
76
- }
77
-
78
- /**
79
- * Send a generic transactional email.
80
- *
81
- * @param {import('nodemailer').Transporter} transport
82
- * @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
83
- * @returns {Promise<void>}
84
- * @throws {Error} If sending the email fails.
85
- */
86
- export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
87
- try {
88
- const info = await transport.sendMail({
89
- from: `"${fromName}" <${from}>`,
90
- to,
91
- subject,
92
- text,
93
- html
94
- });
95
- recordSendResult(true, info.messageId);
96
-
97
- const previewUrl = nodemailer.getTestMessageUrl(info);
98
- if (previewUrl) {
99
- console.log('[email] Preview URL:', previewUrl);
100
- }
101
- return info;
102
- } catch (err) {
103
- recordSendResult(false, err.message);
104
- throw err;
105
- }
106
- }
107
-
108
- /**
109
- * Send an HTML + plain-text form submission notification email.
110
- * Builds a generic table of field→value pairs from the submitted data.
111
- *
112
- * @param {import('nodemailer').Transporter} transport
113
- * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
114
- * @returns {Promise<void>}
115
- * @throws {Error} If sending the email fails.
116
- */
117
- export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
118
- const rows = fields.map(field => {
119
- const val = data[field.name] ?? '';
120
- const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
121
- const safeLabel = escapeHtml(field.label || field.name);
122
- return `
123
- <tr>
124
- <td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
125
- <td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
126
- </tr>`.trim();
127
- }).join('\n');
128
-
129
- const plainRows = fields.map(field => {
130
- const val = data[field.name] ?? '';
131
- return `${field.label || field.name}: ${val}`;
132
- }).join('\n');
133
-
134
- const html = `
135
- <!DOCTYPE html>
136
- <html>
137
- <body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
138
- <h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
139
- <p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
140
- <table style="width:100%;border-collapse:collapse;margin-top:16px;">
141
- ${rows}
142
- </table>
143
- </body>
144
- </html>`.trim();
145
-
146
- const text = `New form submission: ${formTitle}\n\n${plainRows}`;
147
-
148
- try {
149
- const info = await transport.sendMail({
150
- from: `"${fromName}" <${from}>`,
151
- to,
152
- subject,
153
- text,
154
- html
155
- });
156
- recordSendResult(true, info.messageId);
157
-
158
- const previewUrl = nodemailer.getTestMessageUrl(info);
159
- if (previewUrl) {
160
- console.log('[email] Preview URL:', previewUrl);
161
- }
162
- return info;
163
- } catch (err) {
164
- recordSendResult(false, err.message);
165
- throw err;
166
- }
167
- }
1
+ /**
2
+ * Core Email Utility
3
+ * Nodemailer transport factory and generic form email sending utility.
4
+ */
5
+ import nodemailer from 'nodemailer';
6
+
7
+ let lastSendResult = null;
8
+
9
+ /**
10
+ * Return the most recent email send result, or null if no send has occurred.
11
+ *
12
+ * @returns {{ ok: boolean, at: string, info: string|null } | null}
13
+ */
14
+ export function getLastSendResult() {
15
+ return lastSendResult;
16
+ }
17
+
18
+ /**
19
+ * Record the outcome of an email send for health reporting.
20
+ *
21
+ * @param {boolean} ok
22
+ * @param {string|null} info
23
+ * @returns {void}
24
+ */
25
+ function recordSendResult(ok, info) {
26
+ lastSendResult = {
27
+ ok,
28
+ at: new Date().toISOString(),
29
+ info: info || null
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Escape HTML special characters for safe use in email bodies.
35
+ *
36
+ * @param {string} str
37
+ * @returns {string}
38
+ */
39
+ export function escapeHtml(str) {
40
+ return String(str)
41
+ .replace(/&/g, '&amp;')
42
+ .replace(/</g, '&lt;')
43
+ .replace(/>/g, '&gt;')
44
+ .replace(/"/g, '&quot;')
45
+ .replace(/'/g, '&#39;');
46
+ }
47
+
48
+ /**
49
+ * Create a nodemailer transport.
50
+ * Falls back to an Ethereal test account when no SMTP host is configured.
51
+ *
52
+ * @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
53
+ * @returns {Promise<import('nodemailer').Transporter>}
54
+ * @throws {Error} If Ethereal test account creation fails.
55
+ */
56
+ export async function createTransport(smtp) {
57
+ if (smtp?.host) {
58
+ return nodemailer.createTransport({
59
+ host: smtp.host,
60
+ port: smtp.port || 587,
61
+ secure: smtp.secure || false,
62
+ auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
63
+ tls: { rejectUnauthorized: false }
64
+ });
65
+ }
66
+
67
+ // No SMTP configured - use Ethereal for dev/demo
68
+ const testAccount = await nodemailer.createTestAccount();
69
+ console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
70
+ return nodemailer.createTransport({
71
+ host: 'smtp.ethereal.email',
72
+ port: 587,
73
+ secure: false,
74
+ auth: { user: testAccount.user, pass: testAccount.pass }
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Send a generic transactional email.
80
+ *
81
+ * @param {import('nodemailer').Transporter} transport
82
+ * @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
83
+ * @returns {Promise<void>}
84
+ * @throws {Error} If sending the email fails.
85
+ */
86
+ export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
87
+ try {
88
+ const info = await transport.sendMail({
89
+ from: `"${fromName}" <${from}>`,
90
+ to,
91
+ subject,
92
+ text,
93
+ html
94
+ });
95
+ recordSendResult(true, info.messageId);
96
+
97
+ const previewUrl = nodemailer.getTestMessageUrl(info);
98
+ if (previewUrl) {
99
+ console.log('[email] Preview URL:', previewUrl);
100
+ }
101
+ return info;
102
+ } catch (err) {
103
+ recordSendResult(false, err.message);
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Send an HTML + plain-text form submission notification email.
110
+ * Builds a generic table of field→value pairs from the submitted data.
111
+ *
112
+ * @param {import('nodemailer').Transporter} transport
113
+ * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
114
+ * @returns {Promise<void>}
115
+ * @throws {Error} If sending the email fails.
116
+ */
117
+ export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
118
+ const rows = fields.map(field => {
119
+ const val = data[field.name] ?? '';
120
+ const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
121
+ const safeLabel = escapeHtml(field.label || field.name);
122
+ return `
123
+ <tr>
124
+ <td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
125
+ <td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
126
+ </tr>`.trim();
127
+ }).join('\n');
128
+
129
+ const plainRows = fields.map(field => {
130
+ const val = data[field.name] ?? '';
131
+ return `${field.label || field.name}: ${val}`;
132
+ }).join('\n');
133
+
134
+ const html = `
135
+ <!DOCTYPE html>
136
+ <html>
137
+ <body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
138
+ <h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
139
+ <p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
140
+ <table style="width:100%;border-collapse:collapse;margin-top:16px;">
141
+ ${rows}
142
+ </table>
143
+ </body>
144
+ </html>`.trim();
145
+
146
+ const text = `New form submission: ${formTitle}\n\n${plainRows}`;
147
+
148
+ try {
149
+ const info = await transport.sendMail({
150
+ from: `"${fromName}" <${from}>`,
151
+ to,
152
+ subject,
153
+ text,
154
+ html
155
+ });
156
+ recordSendResult(true, info.messageId);
157
+
158
+ const previewUrl = nodemailer.getTestMessageUrl(info);
159
+ if (previewUrl) {
160
+ console.log('[email] Preview URL:', previewUrl);
161
+ }
162
+ return info;
163
+ } catch (err) {
164
+ recordSendResult(false, err.message);
165
+ throw err;
166
+ }
167
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Filter Engine — Structured Query Translator for Collection Entries
2
+ * Filter Engine - Structured Query Translator for Collection Entries
3
3
  *
4
4
  * Defines the structured-filter DSL used by `listEntries` and the `[collection]`
5
5
  * shortcode (via `where_*` attributes). One module, two outputs:
@@ -36,19 +36,19 @@
36
36
  * possible; all comparisons fall back to string compare on NaN. _in / _nin
37
37
  * split on `,` and trim each value. _exists accepts "true" | "false" | "1" | "0".
38
38
  *
39
- * Field paths support dot notation against `entry.data` — e.g.
39
+ * Field paths support dot notation against `entry.data` - e.g.
40
40
  * { 'address.city_eq': 'London' } → matches entry.data.address.city
41
41
  *
42
42
  * ---------------------------------------------------------------------------
43
43
  * Special pseudo-fields
44
44
  * ---------------------------------------------------------------------------
45
- * createdBy — matches against entry.meta.createdBy (not entry.data.*)
46
- * id — matches against entry.id (top-level)
45
+ * createdBy - matches against entry.meta.createdBy (not entry.data.*)
46
+ * id - matches against entry.id (top-level)
47
47
  *
48
48
  * Anything else maps to a path inside entry.data.
49
49
  */
50
50
 
51
- /** Recognised operator suffixes — order matters for greedy matching (_gte before _gt). */
51
+ /** Recognised operator suffixes - order matters for greedy matching (_gte before _gt). */
52
52
  export const OPERATORS = [
53
53
  '_eq', '_ne', '_gte', '_lte', '_gt', '_lt',
54
54
  '_in', '_nin', '_contains', '_starts', '_ends', '_exists'
@@ -208,7 +208,7 @@ export function applyFilter(entries, filter) {
208
208
  * pseudo-fields (`createdBy` → `meta.createdBy`, `id` → `id`).
209
209
  *
210
210
  * Conditions are combined with AND. Multiple operators on the same field are
211
- * merged into a single sub-document — e.g. `{ salary_gte: 100, salary_lte: 200 }`
211
+ * merged into a single sub-document - e.g. `{ salary_gte: 100, salary_lte: 200 }`
212
212
  * becomes `{ 'data.salary': { $gte: 100, $lte: 200 } }`.
213
213
  *
214
214
  * @param {Record<string, unknown>} filter
@@ -63,7 +63,7 @@ export async function listForms() {
63
63
  for (const entry of entries.filter(e => e.endsWith('.json'))) {
64
64
  try {
65
65
  const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
66
- // The filename is the canonical slug — readForm/deleteForm/save all
66
+ // The filename is the canonical slug - readForm/deleteForm/save all
67
67
  // resolve `${slug}.json`, and the [form] shortcode embeds by file.
68
68
  // Normalise so a stale internal slug can't desync list vs read.
69
69
  forms.push({...data, slug: entry.replace(/\.json$/, '')});
@@ -140,7 +140,7 @@ export async function createForm(data) {
140
140
  throw err;
141
141
  } catch (err) {
142
142
  if (err.code === 'FORM_ALREADY_EXISTS') throw err;
143
- // File not found — safe to proceed
143
+ // File not found - safe to proceed
144
144
  }
145
145
 
146
146
  const now = new Date().toISOString();
@@ -155,7 +155,11 @@ export async function createForm(data) {
155
155
  settings: {
156
156
  submitText: 'Submit',
157
157
  successMessage: 'Thank you for your submission.',
158
- layout: 'stacked',
158
+ // New forms start as a 2-column grid: the stacked default made
159
+ // every fresh form a single tall column that had to be re-laid-out
160
+ // by hand before it looked like anything.
161
+ layout: 'grid',
162
+ columns: 2,
159
163
  honeypot: true,
160
164
  rateLimitPerMinute: 3,
161
165
  ...settings
@@ -224,7 +228,11 @@ function buildFormFromCollection(schema) {
224
228
  settings: {
225
229
  submitText: 'Submit',
226
230
  successMessage: 'Thank you for your submission.',
227
- layout: 'stacked',
231
+ // New forms start as a 2-column grid: the stacked default made
232
+ // every fresh form a single tall column that had to be re-laid-out
233
+ // by hand before it looked like anything.
234
+ layout: 'grid',
235
+ columns: 2,
228
236
  honeypot: true,
229
237
  rateLimitPerMinute: 5
230
238
  },
@@ -240,7 +248,7 @@ function buildFormFromCollection(schema) {
240
248
 
241
249
  /**
242
250
  * Ensure a form exists for the given collection schema.
243
- * Creates the form only if absent — never overwrites an existing one.
251
+ * Creates the form only if absent - never overwrites an existing one.
244
252
  * Skips system collections (roles, user-profiles).
245
253
  *
246
254
  * @param {object} schema - Collection schema
@@ -282,11 +290,11 @@ function toCollectionFieldType(type) {
282
290
  *
283
291
  * This is the inverse of {@link ensureFormForCollection}. A form whose
284
292
  * `collection` action points at a slug that was never created is permanently
285
- * broken: listing submissions 404s and — worse — every public submission is
293
+ * broken: listing submissions 404s and - worse - every public submission is
286
294
  * rejected and lost. Provisioning the collection from the form's own fields
287
295
  * makes such forms self-heal instead of silently dropping data.
288
296
  *
289
- * Creates the collection only if absent — never overwrites an existing one.
297
+ * Creates the collection only if absent - never overwrites an existing one.
290
298
  * No-op when the form disables collection storage.
291
299
  *
292
300
  * @param {object} form - Form definition
@@ -296,7 +304,7 @@ export async function ensureCollectionForForm(form) {
296
304
  if (!form || typeof form !== 'object') return null;
297
305
 
298
306
  const action = form.actions?.collection;
299
- // Respect an explicitly disabled collection store — nothing to provision.
307
+ // Respect an explicitly disabled collection store - nothing to provision.
300
308
  if (action && action.enabled === false) return null;
301
309
 
302
310
  const targetSlug = (action && action.slug) ? action.slug : form.slug;
@@ -328,7 +336,7 @@ export async function ensureCollectionForForm(form) {
328
336
  slug: targetSlug,
329
337
  description: form.description || `Submissions for the "${form.title || targetSlug}" form.`,
330
338
  fields,
331
- // Submissions can contain PII — lock the backing collection to admin
339
+ // Submissions can contain PII - lock the backing collection to admin
332
340
  // access so it is never exposed through the public collections API.
333
341
  // (Mirrors the access config the POST /forms create path applies.)
334
342
  api: {
@@ -340,7 +348,7 @@ export async function ensureCollectionForForm(form) {
340
348
  meta: { generatedFromForm: form.slug }
341
349
  });
342
350
  } catch (err) {
343
- // Lost a race with a concurrent provision (EEXIST) — re-read and use it.
351
+ // Lost a race with a concurrent provision (EEXIST) - re-read and use it.
344
352
  const now = await getCollection(targetSlug);
345
353
  if (now) return now;
346
354
  throw err;
@@ -246,7 +246,7 @@ async function safeCheck(label, fn) {
246
246
  * Aggregate site + server health checks.
247
247
  *
248
248
  * Returns the cached result if it's within the 30 s TTL; otherwise runs all eight
249
- * checks in parallel and caches the result. Per-check errors are isolated — an
249
+ * checks in parallel and caches the result. Per-check errors are isolated - an
250
250
  * unexpected throw degrades only that check to `fail`, never the whole call.
251
251
  *
252
252
  * Note: no in-flight deduplication. Two concurrent cache misses will both run the
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Hook System
3
3
  * Provides three extensibility mechanisms for plugins:
4
- * 1. EventEmitter — fire-and-forget content lifecycle events
5
- * 2. Shortcode registry — register custom [shortcode] handlers at startup
6
- * 3. Sanitize rule extensions — expand the HTML whitelist for plugin output
7
- * 4. Transform pipeline — modify markdown/render values at named hook points
4
+ * 1. EventEmitter - fire-and-forget content lifecycle events
5
+ * 2. Shortcode registry - register custom [shortcode] handlers at startup
6
+ * 3. Sanitize rule extensions - expand the HTML whitelist for plugin output
7
+ * 4. Transform pipeline - modify markdown/render values at named hook points
8
8
  */
9
9
  import {EventEmitter} from 'node:events';
10
10
 
@@ -47,7 +47,7 @@ export function getShortcodeProcessors() {
47
47
  }
48
48
 
49
49
  // ---------------------------------------------------------------------------
50
- // Menu-location registry — delegated to server/services/menus.js
50
+ // Menu-location registry - delegated to server/services/menus.js
51
51
  // ---------------------------------------------------------------------------
52
52
 
53
53
  /**
@@ -65,14 +65,14 @@ export async function registerMenuLocation(slot, def) {
65
65
  }
66
66
 
67
67
  // ---------------------------------------------------------------------------
68
- // Admin sidebar items — plugin-registered runtime injection
68
+ // Admin sidebar items - plugin-registered runtime injection
69
69
  // ---------------------------------------------------------------------------
70
70
 
71
71
  const _registeredSidebarItems = [];
72
72
 
73
73
  /**
74
74
  * Register a sidebar item from a plugin. Items are merged into the rendered
75
- * admin sidebar at render time — they are NOT persisted into menu JSON.
75
+ * admin sidebar at render time - they are NOT persisted into menu JSON.
76
76
  * Plugins re-register on each server boot via their setupPlugin hook.
77
77
  *
78
78
  * @param {{parent?: string, item: {text: string, url: string, icon?: string, permission?: string, items?: object[]}}} opts
@@ -139,8 +139,8 @@ function applyAdjustments(pipeline, adj) {
139
139
  * @param {import('sharp').Sharp} pipeline
140
140
  * @param {object} wm
141
141
  * @param {string} wm.image Filename in MEDIA_DIR
142
- * @param {number} wm.opacity 0–1
143
- * @param {number} wm.scale Fraction of image width (0–1)
142
+ * @param {number} wm.opacity 0-1
143
+ * @param {number} wm.scale Fraction of image width (0-1)
144
144
  * @param {string} wm.position e.g. 'bottom-right'
145
145
  * @returns {Promise<import('sharp').Sharp>}
146
146
  * @throws {Error} If watermark file cannot be read
@@ -214,7 +214,7 @@ function applyBorder(pipeline, border) {
214
214
  * @param {import('sharp').Sharp} pipeline
215
215
  * @param {object} format
216
216
  * @param {string} format.type 'jpeg' | 'png' | 'webp'
217
- * @param {number} [format.quality] 1–100
217
+ * @param {number} [format.quality] 1-100
218
218
  * @returns {import('sharp').Sharp}
219
219
  */
220
220
  function applyFormat(pipeline, format) {
@@ -262,7 +262,7 @@ function applyFormat(pipeline, format) {
262
262
  export async function transformImage(filename, operations = {}, outputFilename = null) {
263
263
  const srcPath = path.join(MEDIA_DIR, filename);
264
264
 
265
- // Read into buffer first — avoids file-lock when overwriting the source
265
+ // Read into buffer first - avoids file-lock when overwriting the source
266
266
  const inputBuffer = await fs.readFile(srcPath);
267
267
 
268
268
  let pipeline = sharp(inputBuffer);
@@ -37,7 +37,7 @@ export async function registerInstance(fingerprint) {
37
37
 
38
38
  if (!MANAGER_URL) return null;
39
39
  if (!INSTANCE_SECRET) {
40
- console.warn('[managerClient] INSTANCE_SECRET not set — skipping registerInstance');
40
+ console.warn('[managerClient] INSTANCE_SECRET not set - skipping registerInstance');
41
41
  return null;
42
42
  }
43
43