domma-cms 0.2.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1200
  3. package/admin/dist/domma/domma-tools.css +2313 -0
  4. package/admin/dist/domma/domma-tools.min.js +10 -0
  5. package/admin/index.html +4 -0
  6. package/admin/js/api.js +1 -242
  7. package/admin/js/app.js +9 -279
  8. package/admin/js/config/sidebar-config.js +1 -115
  9. package/admin/js/lib/card.js +1 -63
  10. package/admin/js/lib/image-editor.js +1 -869
  11. package/admin/js/lib/markdown-toolbar.js +54 -421
  12. package/admin/js/templates/action-editor.html +171 -0
  13. package/admin/js/templates/actions-list.html +19 -0
  14. package/admin/js/templates/api-reference.html +1411 -0
  15. package/admin/js/templates/block-editor.html +158 -0
  16. package/admin/js/templates/blocks.html +8 -0
  17. package/admin/js/templates/collection-editor.html +47 -0
  18. package/admin/js/templates/collection-entries.html +3 -0
  19. package/admin/js/templates/collections.html +51 -4
  20. package/admin/js/templates/documentation.html +258 -0
  21. package/admin/js/templates/form-editor.html +238 -0
  22. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  23. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  24. package/admin/js/templates/layouts.html +44 -7
  25. package/admin/js/templates/login.html +29 -4
  26. package/admin/js/templates/my-profile.html +17 -0
  27. package/admin/js/templates/page-editor.html +48 -0
  28. package/admin/js/templates/pages.html +6 -1
  29. package/admin/js/templates/pro-docs.html +259 -0
  30. package/admin/js/templates/role-editor.html +59 -0
  31. package/admin/js/templates/roles.html +10 -0
  32. package/admin/js/templates/settings.html +137 -18
  33. package/admin/js/templates/tutorials.html +81 -0
  34. package/admin/js/templates/user-editor.html +7 -0
  35. package/admin/js/templates/users.html +3 -1
  36. package/admin/js/templates/view-editor.html +201 -0
  37. package/admin/js/templates/view-preview.html +51 -0
  38. package/admin/js/templates/views-list.html +19 -0
  39. package/admin/js/views/action-editor.js +1 -0
  40. package/admin/js/views/actions-list.js +1 -0
  41. package/admin/js/views/api-reference.js +1 -0
  42. package/admin/js/views/block-editor.js +8 -0
  43. package/admin/js/views/blocks.js +4 -0
  44. package/admin/js/views/collection-editor.js +3 -487
  45. package/admin/js/views/collection-entries.js +1 -484
  46. package/admin/js/views/collections.js +1 -153
  47. package/admin/js/views/dashboard.js +1 -56
  48. package/admin/js/views/documentation.js +1 -12
  49. package/admin/js/views/form-editor.js +8 -0
  50. package/admin/js/views/form-submissions.js +1 -0
  51. package/admin/js/views/forms.js +1 -0
  52. package/admin/js/views/index.js +1 -39
  53. package/admin/js/views/layouts.js +9 -42
  54. package/admin/js/views/login.js +7 -251
  55. package/admin/js/views/media.js +1 -240
  56. package/admin/js/views/my-profile.js +1 -0
  57. package/admin/js/views/navigation.js +14 -212
  58. package/admin/js/views/page-editor.js +72 -661
  59. package/admin/js/views/pages.js +5 -72
  60. package/admin/js/views/plugins.js +13 -90
  61. package/admin/js/views/pro-docs.js +1 -0
  62. package/admin/js/views/role-editor.js +1 -0
  63. package/admin/js/views/roles.js +4 -0
  64. package/admin/js/views/settings.js +3 -199
  65. package/admin/js/views/tutorials.js +1 -12
  66. package/admin/js/views/user-editor.js +1 -88
  67. package/admin/js/views/users.js +4 -76
  68. package/admin/js/views/view-editor.js +1 -0
  69. package/admin/js/views/view-preview.js +1 -0
  70. package/admin/js/views/views-list.js +1 -0
  71. package/bin/cli.js +1 -1
  72. package/config/auth.json +2 -17
  73. package/config/connections.json.bak +9 -0
  74. package/config/connections.json.example +9 -0
  75. package/config/navigation.json +15 -0
  76. package/config/plugins.json +19 -29
  77. package/config/server.json +6 -6
  78. package/config/site.json +17 -6
  79. package/package.json +24 -10
  80. package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
  81. package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
  82. package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
  83. package/plugins/domma-effects/public/celebrations/index.js +1 -535
  84. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
  85. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
  86. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
  87. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
  88. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
  89. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
  90. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
  91. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
  92. package/plugins/example-analytics/stats.json +21 -12
  93. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  94. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  95. package/plugins/theme-roller/config.js +1 -0
  96. package/plugins/theme-roller/plugin.js +233 -0
  97. package/plugins/theme-roller/plugin.json +31 -0
  98. package/plugins/theme-roller/public/active-theme.css +0 -0
  99. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  100. package/public/css/forms.css +1 -0
  101. package/public/css/site.css +1 -302
  102. package/public/js/btt.js +1 -90
  103. package/public/js/cookie-consent.js +1 -61
  104. package/public/js/form-logic-engine.js +1 -0
  105. package/public/js/forms.js +1 -0
  106. package/public/js/site.js +1 -204
  107. package/scripts/build.js +194 -129
  108. package/scripts/pro.js +254 -0
  109. package/scripts/reset.js +33 -8
  110. package/scripts/seed.js +343 -78
  111. package/scripts/setup.js +5 -4
  112. package/server/middleware/auth.js +136 -97
  113. package/server/routes/api/actions.js +200 -0
  114. package/server/routes/api/auth.js +292 -116
  115. package/server/routes/api/blocks.js +84 -0
  116. package/server/routes/api/collections.js +88 -23
  117. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +483 -505
  118. package/server/routes/api/layouts.js +49 -25
  119. package/server/routes/api/media.js +118 -93
  120. package/server/routes/api/navigation.js +40 -37
  121. package/server/routes/api/pages.js +132 -118
  122. package/server/routes/api/plugins.js +6 -3
  123. package/server/routes/api/settings.js +104 -89
  124. package/server/routes/api/users.js +27 -21
  125. package/server/routes/api/views.js +148 -0
  126. package/server/routes/public.js +124 -108
  127. package/server/server.js +269 -173
  128. package/server/services/actions.js +387 -0
  129. package/server/services/adapterRegistry.js +98 -0
  130. package/server/services/adapters/FileAdapter.js +192 -0
  131. package/server/services/adapters/MongoAdapter.js +220 -0
  132. package/server/services/blocks.js +162 -0
  133. package/server/services/collections.js +74 -86
  134. package/server/services/connectionManager.js +102 -0
  135. package/server/services/content.js +312 -307
  136. package/{plugins/form-builder → server/services}/email.js +126 -103
  137. package/server/services/forms.js +173 -0
  138. package/server/services/markdown.js +1378 -648
  139. package/server/services/permissionRegistry.js +173 -0
  140. package/server/services/presetCollections.js +251 -0
  141. package/server/services/renderer.js +75 -1
  142. package/server/services/roles.js +227 -0
  143. package/server/services/rowAccess.js +104 -0
  144. package/server/services/userProfiles.js +199 -0
  145. package/server/services/users.js +281 -212
  146. package/server/services/views.js +280 -0
  147. package/server/templates/page.html +119 -113
  148. package/plugins/form-builder/admin/templates/form-editor.html +0 -171
  149. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  150. package/plugins/form-builder/admin/views/form-editor.js +0 -1442
  151. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  152. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  153. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  154. package/plugins/form-builder/config.js +0 -9
  155. package/plugins/form-builder/data/forms/consent.json +0 -104
  156. package/plugins/form-builder/data/forms/contact-details.json +0 -63
  157. package/plugins/form-builder/data/forms/contacts.json +0 -66
  158. package/plugins/form-builder/data/submissions/consent.json +0 -13
  159. package/plugins/form-builder/data/submissions/contact-details.json +0 -1
  160. package/plugins/form-builder/data/submissions/contacts.json +0 -26
  161. package/plugins/form-builder/plugin.json +0 -52
  162. package/plugins/form-builder/public/form-logic-engine.js +0 -568
  163. package/plugins/form-builder/public/inject-body.html +0 -352
  164. package/plugins/form-builder/public/inject-head.html +0 -58
  165. package/plugins/form-builder/public/package.json +0 -1
  166. package/scripts/copy-domma.js +0 -48
@@ -1,103 +1,126 @@
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, '&')
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
- }
1
+ /**
2
+ * Core Email Utility
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('[email] 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 a generic transactional email.
53
+ *
54
+ * @param {import('nodemailer').Transporter} transport
55
+ * @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
56
+ * @returns {Promise<void>}
57
+ * @throws {Error} If sending the email fails.
58
+ */
59
+ export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
60
+ const info = await transport.sendMail({
61
+ from: `"${fromName}" <${from}>`,
62
+ to,
63
+ subject,
64
+ text,
65
+ html
66
+ });
67
+
68
+ const previewUrl = nodemailer.getTestMessageUrl(info);
69
+ if (previewUrl) {
70
+ console.log('[email] Preview URL:', previewUrl);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Send an HTML + plain-text form submission notification email.
76
+ * Builds a generic table of field→value pairs from the submitted data.
77
+ *
78
+ * @param {import('nodemailer').Transporter} transport
79
+ * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
80
+ * @returns {Promise<void>}
81
+ * @throws {Error} If sending the email fails.
82
+ */
83
+ export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
84
+ const rows = fields.map(field => {
85
+ const val = data[field.name] ?? '';
86
+ const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
87
+ const safeLabel = escapeHtml(field.label || field.name);
88
+ return `
89
+ <tr>
90
+ <td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
91
+ <td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
92
+ </tr>`.trim();
93
+ }).join('\n');
94
+
95
+ const plainRows = fields.map(field => {
96
+ const val = data[field.name] ?? '';
97
+ return `${field.label || field.name}: ${val}`;
98
+ }).join('\n');
99
+
100
+ const html = `
101
+ <!DOCTYPE html>
102
+ <html>
103
+ <body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
104
+ <h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
105
+ <p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
106
+ <table style="width:100%;border-collapse:collapse;margin-top:16px;">
107
+ ${rows}
108
+ </table>
109
+ </body>
110
+ </html>`.trim();
111
+
112
+ const text = `New form submission: ${formTitle}\n\n${plainRows}`;
113
+
114
+ const info = await transport.sendMail({
115
+ from: `"${fromName}" <${from}>`,
116
+ to,
117
+ subject,
118
+ text,
119
+ html
120
+ });
121
+
122
+ const previewUrl = nodemailer.getTestMessageUrl(info);
123
+ if (previewUrl) {
124
+ console.log('[email] Preview URL:', previewUrl);
125
+ }
126
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Core Forms Service
3
+ * CRUD operations for form definitions stored in content/forms/.
4
+ * Submissions are stored exclusively in Collections (slug matching form slug).
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import {fileURLToPath} from 'url';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const ROOT = path.resolve(__dirname, '..', '..');
12
+ export const FORMS_DIR = path.join(ROOT, 'content', 'forms');
13
+
14
+ /**
15
+ * Ensure the forms directory exists.
16
+ *
17
+ * @returns {Promise<void>}
18
+ */
19
+ export async function ensureFormsDir() {
20
+ await fs.mkdir(FORMS_DIR, { recursive: true });
21
+ }
22
+
23
+ /**
24
+ * Read a single form definition by slug.
25
+ *
26
+ * @param {string} slug
27
+ * @returns {Promise<object>}
28
+ * @throws {Error} If the form file does not exist or cannot be parsed.
29
+ */
30
+ export async function readForm(slug) {
31
+ const file = path.join(FORMS_DIR, `${slug}.json`);
32
+ return JSON.parse(await fs.readFile(file, 'utf8'));
33
+ }
34
+
35
+ /**
36
+ * Write a form definition to disk.
37
+ *
38
+ * @param {string} slug
39
+ * @param {object} data
40
+ * @returns {Promise<void>}
41
+ */
42
+ export async function writeForm(slug, data) {
43
+ await ensureFormsDir();
44
+ const file = path.join(FORMS_DIR, `${slug}.json`);
45
+ await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
46
+ }
47
+
48
+ /**
49
+ * List all form definitions.
50
+ *
51
+ * @returns {Promise<object[]>}
52
+ */
53
+ export async function listForms() {
54
+ let entries;
55
+ try {
56
+ entries = await fs.readdir(FORMS_DIR);
57
+ } catch {
58
+ return [];
59
+ }
60
+ const forms = [];
61
+ for (const entry of entries.filter(e => e.endsWith('.json'))) {
62
+ try {
63
+ const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
64
+ forms.push(data);
65
+ } catch {
66
+ // skip malformed
67
+ }
68
+ }
69
+ return forms;
70
+ }
71
+
72
+ /**
73
+ * Delete a form definition by slug.
74
+ *
75
+ * @param {string} slug
76
+ * @returns {Promise<void>}
77
+ * @throws {Error} If the form file does not exist.
78
+ */
79
+ export async function deleteForm(slug) {
80
+ await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
81
+ }
82
+
83
+ /**
84
+ * Convert a string to a URL-friendly slug.
85
+ *
86
+ * @param {string} str
87
+ * @returns {string}
88
+ */
89
+ export function slugify(str) {
90
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
91
+ }
92
+
93
+ /** System collections that should never have an auto-generated public form. */
94
+ const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
95
+
96
+ /**
97
+ * Collection field type → form field type mapping.
98
+ *
99
+ * @param {string} type
100
+ * @returns {string}
101
+ */
102
+ function toFormFieldType(type) {
103
+ const map = {
104
+ string: 'string', text: 'string', email: 'email', tel: 'tel',
105
+ number: 'number', textarea: 'textarea', select: 'select', radio: 'radio',
106
+ checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
107
+ date: 'date', time: 'time', url: 'url', hidden: 'hidden'
108
+ };
109
+ return map[type] || 'string';
110
+ }
111
+
112
+ /**
113
+ * Build a form definition object from a collection schema.
114
+ *
115
+ * @param {object} schema - Collection schema
116
+ * @returns {object} Form definition
117
+ */
118
+ function buildFormFromCollection(schema) {
119
+ const now = new Date().toISOString();
120
+ const fields = (schema.fields || []).map(f => {
121
+ const field = {
122
+ name: f.name,
123
+ type: toFormFieldType(f.type),
124
+ label: f.label || f.name,
125
+ required: !!f.required,
126
+ placeholder: f.placeholder || '',
127
+ helper: f.helper || ''
128
+ };
129
+ if (f.options) field.options = f.options;
130
+ if (f.validation) field.validation = f.validation;
131
+ return field;
132
+ });
133
+
134
+ return {
135
+ slug: schema.slug,
136
+ title: schema.title,
137
+ description: schema.description || '',
138
+ fields,
139
+ settings: {
140
+ submitText: 'Submit',
141
+ successMessage: 'Thank you for your submission.',
142
+ layout: 'stacked',
143
+ honeypot: true,
144
+ rateLimitPerMinute: 5
145
+ },
146
+ actions: {
147
+ email: {enabled: false, recipients: '', subjectPrefix: `[${schema.slug}]`},
148
+ webhook: {enabled: false, url: '', method: 'POST'},
149
+ collection: {enabled: true, slug: schema.slug}
150
+ },
151
+ createdAt: now,
152
+ updatedAt: now
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Ensure a form exists for the given collection schema.
158
+ * Creates the form only if absent — never overwrites an existing one.
159
+ * Skips system collections (roles, user-profiles).
160
+ *
161
+ * @param {object} schema - Collection schema
162
+ * @returns {Promise<void>}
163
+ */
164
+ export async function ensureFormForCollection(schema) {
165
+ if (NO_FORM_SLUGS.has(schema.slug)) return;
166
+ await ensureFormsDir();
167
+ const filePath = path.join(FORMS_DIR, `${schema.slug}.json`);
168
+ try {
169
+ await fs.access(filePath);
170
+ } catch {
171
+ await writeForm(schema.slug, buildFormFromCollection(schema));
172
+ }
173
+ }