domma-cms 0.8.7 → 0.9.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 (34) hide show
  1. package/README.md +31 -9
  2. package/admin/js/templates/action-editor.html +5 -0
  3. package/admin/js/templates/block-editor.html +5 -0
  4. package/admin/js/templates/collection-editor.html +7 -0
  5. package/admin/js/templates/effects.html +147 -72
  6. package/admin/js/templates/form-editor.html +7 -0
  7. package/admin/js/templates/page-editor.html +5 -0
  8. package/admin/js/templates/view-editor.html +5 -0
  9. package/admin/js/views/action-editor.js +1 -1
  10. package/admin/js/views/block-editor.js +4 -4
  11. package/admin/js/views/collection-editor.js +4 -4
  12. package/admin/js/views/collections.js +1 -1
  13. package/admin/js/views/effects.js +1 -1
  14. package/admin/js/views/form-editor.js +1 -1
  15. package/admin/js/views/navigation.js +13 -12
  16. package/admin/js/views/page-editor.js +11 -11
  17. package/admin/js/views/pages.js +2 -2
  18. package/admin/js/views/view-editor.js +1 -1
  19. package/package.json +1 -1
  20. package/plugins/contacts/collections/user-contact-groups/schema.json +35 -0
  21. package/plugins/contacts/collections/user-contacts/schema.json +71 -0
  22. package/plugins/contacts/plugin.js +1 -55
  23. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  24. package/plugins/garage/plugin.js +0 -40
  25. package/plugins/notes/collections/user-notes/schema.json +53 -0
  26. package/plugins/notes/plugin.js +1 -47
  27. package/plugins/todo/collections/todos/schema.json +59 -0
  28. package/plugins/todo/plugin.js +1 -48
  29. package/server/routes/api/blocks.js +19 -43
  30. package/server/routes/api/forms.js +8 -1
  31. package/server/services/blocks.js +124 -5
  32. package/server/services/collections.js +17 -3
  33. package/server/services/forms.js +78 -0
  34. package/server/services/plugins.js +197 -2
@@ -0,0 +1,59 @@
1
+ {
2
+ "slug": "todos",
3
+ "title": "Todos",
4
+ "description": "Todo items managed by the Todo plugin.",
5
+ "plugin": "todo",
6
+ "fields": [
7
+ {
8
+ "name": "text",
9
+ "label": "Task",
10
+ "type": "text",
11
+ "required": true
12
+ },
13
+ {
14
+ "name": "status",
15
+ "label": "Status",
16
+ "type": "text",
17
+ "required": false
18
+ },
19
+ {
20
+ "name": "priority",
21
+ "label": "Priority",
22
+ "type": "text",
23
+ "required": false
24
+ },
25
+ {
26
+ "name": "dueAt",
27
+ "label": "Due Date",
28
+ "type": "text",
29
+ "required": false
30
+ },
31
+ {
32
+ "name": "userId",
33
+ "label": "User ID",
34
+ "type": "text",
35
+ "required": false
36
+ }
37
+ ],
38
+ "api": {
39
+ "create": {
40
+ "enabled": true,
41
+ "access": "admin"
42
+ },
43
+ "read": {
44
+ "enabled": true,
45
+ "access": "admin"
46
+ },
47
+ "update": {
48
+ "enabled": false,
49
+ "access": "admin"
50
+ },
51
+ "delete": {
52
+ "enabled": false,
53
+ "access": "admin"
54
+ }
55
+ },
56
+ "storage": {
57
+ "adapter": "file"
58
+ }
59
+ }
@@ -4,44 +4,10 @@ import {
4
4
  createEntry,
5
5
  updateEntry,
6
6
  deleteEntry,
7
- getEntry,
8
- getCollection,
9
- createCollection
7
+ getEntry
10
8
  } from '../../server/services/collections.js';
11
9
 
12
10
  const SLUG = 'todos';
13
- const STORAGE = defaultConfig.storage ?? {adapter: 'file'};
14
-
15
- const FIELDS = [
16
- {name: 'text', label: 'Task', type: 'text', required: true},
17
- {name: 'status', label: 'Status', type: 'text', required: false},
18
- {name: 'priority', label: 'Priority', type: 'text', required: false},
19
- {name: 'dueAt', label: 'Due Date', type: 'text', required: false},
20
- {name: 'userId', label: 'User ID', type: 'text', required: false}
21
- ];
22
-
23
- /**
24
- * Lifecycle: create the todos collection (MongoDB-backed) on plugin enable.
25
- */
26
- export async function onEnable({services: {collections}}) {
27
- const existing = await collections.getCollection(SLUG).catch(() => null);
28
- if (existing) return;
29
- await collections.createCollection({
30
- title: 'Todos',
31
- slug: SLUG,
32
- description: 'Todo items managed by the Todo plugin.',
33
- fields: FIELDS,
34
- storage: STORAGE
35
- });
36
- }
37
-
38
- /**
39
- * Lifecycle: remove the todos collection on plugin disable.
40
- */
41
- export async function onDisable({services: {collections}}) {
42
- await collections.deleteCollection(SLUG).catch(() => {
43
- });
44
- }
45
11
 
46
12
  /** Flatten a collection entry into the shape the admin view expects. */
47
13
  function toTodo(entry) {
@@ -57,19 +23,6 @@ export default async function todoPlugin(fastify, options) {
57
23
  const { authenticate } = options.auth;
58
24
  const config = {...defaultConfig, ...(options.settings || {})};
59
25
  const scope = config.scope ?? 'user';
60
- const storage = config.storage ?? STORAGE;
61
-
62
- // Auto-create the collection if it doesn't exist yet.
63
- const existing = await getCollection(SLUG).catch(() => null);
64
- if (!existing) {
65
- await createCollection({
66
- title: 'Todos',
67
- slug: SLUG,
68
- description: 'Todo items managed by the Todo plugin.',
69
- fields: FIELDS,
70
- storage
71
- }).catch(err => fastify.log.warn(`[todo] Collection setup: ${err.message}`));
72
- }
73
26
 
74
27
  function userId(request) {
75
28
  return scope === 'user' ? (request.user?.id ?? request.user?.sub ?? null) : null;
@@ -6,20 +6,8 @@
6
6
  * PUT /api/blocks/:name - create or update block
7
7
  * DELETE /api/blocks/:name - delete block
8
8
  */
9
- import path from 'path';
10
- import fs from 'fs/promises';
11
- import {fileURLToPath} from 'url';
12
9
  import {authenticate, requirePermission} from '../../middleware/auth.js';
13
-
14
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
- const ROOT = path.resolve(__dirname, '../../..');
16
- const BLOCKS_DIR = path.join(ROOT, 'content', 'blocks');
17
-
18
- const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
19
-
20
- function blockPath(name) {
21
- return path.join(BLOCKS_DIR, `${name}.html`);
22
- }
10
+ import {deleteBlock, getBlock, listBlocks, saveBlock} from '../../services/blocks.js';
23
11
 
24
12
  export async function blocksRoutes(fastify) {
25
13
  const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
@@ -28,57 +16,45 @@ export async function blocksRoutes(fastify) {
28
16
 
29
17
  // List all blocks
30
18
  fastify.get('/blocks', canRead, async () => {
31
- await fs.mkdir(BLOCKS_DIR, {recursive: true});
32
- const files = await fs.readdir(BLOCKS_DIR);
33
- const blocks = [];
34
- for (const file of files.filter(f => f.endsWith('.html'))) {
35
- const name = file.slice(0, -5);
36
- const stat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
37
- blocks.push({
38
- name,
39
- size: stat?.size ?? 0,
40
- updatedAt: stat?.mtime?.toISOString() ?? null
41
- });
42
- }
43
- return blocks.sort((a, b) => a.name.localeCompare(b.name));
19
+ return listBlocks();
44
20
  });
45
21
 
46
22
  // Get single block
47
23
  fastify.get('/blocks/:name', canRead, async (request, reply) => {
48
24
  const {name} = request.params;
49
- if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name'});
50
-
51
25
  try {
52
- const content = await fs.readFile(blockPath(name), 'utf8');
53
- return {name, content};
54
- } catch {
55
- return reply.status(404).send({error: 'Block not found'});
26
+ return await getBlock(name);
27
+ } catch (err) {
28
+ if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
29
+ if (err.code === 'ENOENT') return reply.status(404).send({error: 'Block not found'});
30
+ throw err;
56
31
  }
57
32
  });
58
33
 
59
34
  // Create or update block
60
35
  fastify.put('/blocks/:name', canUpdate, async (request, reply) => {
61
36
  const {name} = request.params;
62
- if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name. Use lowercase letters, digits, and hyphens only.'});
63
-
64
- const {content} = request.body || {};
37
+ const {content, bundled} = request.body || {};
65
38
  if (typeof content !== 'string') return reply.status(400).send({error: 'content (string) is required'});
66
39
 
67
- await fs.mkdir(BLOCKS_DIR, {recursive: true});
68
- await fs.writeFile(blockPath(name), content, 'utf8');
69
- return {success: true, name};
40
+ try {
41
+ return await saveBlock(name, content, {bundled: !!bundled});
42
+ } catch (err) {
43
+ if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
44
+ throw err;
45
+ }
70
46
  });
71
47
 
72
48
  // Delete block
73
49
  fastify.delete('/blocks/:name', canDelete, async (request, reply) => {
74
50
  const {name} = request.params;
75
- if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name'});
76
-
77
51
  try {
78
- await fs.unlink(blockPath(name));
52
+ await deleteBlock(name);
79
53
  return reply.status(204).send();
80
- } catch {
81
- return reply.status(404).send({error: 'Block not found'});
54
+ } catch (err) {
55
+ if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
56
+ if (err.code === 'ENOENT') return reply.status(404).send({error: 'Block not found'});
57
+ throw err;
82
58
  }
83
59
  });
84
60
  }
@@ -456,10 +456,17 @@ export async function formsRoutes(fastify) {
456
456
 
457
457
  hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
458
458
 
459
+ // Template interpolation for successRedirect
460
+ let redirect = settings.successRedirect || null;
461
+ if (redirect && entry?.id) {
462
+ redirect = redirect.replace(/\{\{entryId\}\}/g, entry.id);
463
+ }
464
+
459
465
  return {
460
466
  ok: true,
467
+ entryId: entry?.id || null,
461
468
  message: settings.successMessage || 'Thank you for your submission.',
462
- redirect: settings.successRedirect || null
469
+ redirect: redirect
463
470
  };
464
471
  });
465
472
 
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Blocks Service
3
- * Seeds default block templates for built-in forms.
4
- * Never overwrites existing files — user customisations are preserved.
3
+ * CRUD operations and seeding for reusable HTML block templates in content/blocks/.
4
+ * Never overwrites existing files during seeding — user customisations are preserved.
5
5
  */
6
- import {access, writeFile} from 'fs/promises';
6
+ import fs from 'fs/promises';
7
7
  import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
9
 
@@ -19,10 +19,10 @@ const BLOCKS_DIR = path.resolve(__dirname, '../../content/blocks');
19
19
  async function seedBlock(name, content) {
20
20
  const filePath = path.join(BLOCKS_DIR, `${name}.html`);
21
21
  try {
22
- await access(filePath);
22
+ await fs.access(filePath);
23
23
  // File exists — leave it alone
24
24
  } catch {
25
- await writeFile(filePath, content.trim() + '\n', 'utf8');
25
+ await fs.writeFile(filePath, content.trim() + '\n', 'utf8');
26
26
  }
27
27
  }
28
28
 
@@ -145,6 +145,125 @@ const BLOCKS = {
145
145
 
146
146
  };
147
147
 
148
+ // ---------------------------------------------------------------------------
149
+ // Validation
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /** Block names must be lowercase alphanumeric + hyphens, no path traversal. */
153
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
154
+
155
+ function assertValidName(name) {
156
+ if (!NAME_RE.test(name)) {
157
+ const err = new Error('Invalid block name. Use lowercase letters, digits, and hyphens only.');
158
+ err.code = 'INVALID_NAME';
159
+ throw err;
160
+ }
161
+ }
162
+
163
+ function blockFilePath(name) {
164
+ return path.join(BLOCKS_DIR, `${name}.html`);
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // CRUD service functions
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * List all blocks in the blocks directory.
173
+ *
174
+ * @returns {Promise<Array<{name: string, size: number, updatedAt: string|null}>>}
175
+ */
176
+ export async function listBlocks() {
177
+ await fs.mkdir(BLOCKS_DIR, {recursive: true});
178
+ const files = await fs.readdir(BLOCKS_DIR);
179
+ const blocks = [];
180
+ for (const file of files.filter(f => f.endsWith('.html'))) {
181
+ const name = file.slice(0, -5);
182
+ const fileStat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
183
+ let bundled = false;
184
+ try {
185
+ const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
186
+ bundled = !!meta.bundled;
187
+ } catch { /* no meta file */ }
188
+ blocks.push({
189
+ name,
190
+ size: fileStat?.size ?? 0,
191
+ updatedAt: fileStat?.mtime?.toISOString() ?? null,
192
+ bundled,
193
+ });
194
+ }
195
+ return blocks.sort((a, b) => a.name.localeCompare(b.name));
196
+ }
197
+
198
+ /**
199
+ * Read a single block's content by name.
200
+ *
201
+ * @param {string} name - Block name (without .html extension)
202
+ * @returns {Promise<{name: string, content: string}>}
203
+ * @throws {Error} With code INVALID_NAME or ENOENT when not found
204
+ */
205
+ export async function getBlock(name) {
206
+ assertValidName(name);
207
+ try {
208
+ const content = await fs.readFile(blockFilePath(name), 'utf8');
209
+ let bundled = false;
210
+ try {
211
+ const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
212
+ bundled = !!meta.bundled;
213
+ } catch { /* no meta file */ }
214
+ return {name, content, bundled};
215
+ } catch (err) {
216
+ if (err.code === 'ENOENT') {
217
+ const notFound = new Error('Block not found');
218
+ notFound.code = 'ENOENT';
219
+ throw notFound;
220
+ }
221
+ throw err;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Create or update a block file (upsert).
227
+ *
228
+ * @param {string} name - Block name (without .html extension)
229
+ * @param {string} content - HTML template content
230
+ * @returns {Promise<{success: boolean, name: string}>}
231
+ * @throws {Error} With code INVALID_NAME on bad name
232
+ */
233
+ export async function saveBlock(name, content, {bundled} = {}) {
234
+ assertValidName(name);
235
+ await fs.mkdir(BLOCKS_DIR, {recursive: true});
236
+ await fs.writeFile(blockFilePath(name), content, 'utf8');
237
+ const metaPath = path.join(BLOCKS_DIR, `${name}.meta.json`);
238
+ if (bundled) {
239
+ await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
240
+ } else {
241
+ await fs.unlink(metaPath).catch(() => {});
242
+ }
243
+ return {success: true, name};
244
+ }
245
+
246
+ /**
247
+ * Delete a block file.
248
+ *
249
+ * @param {string} name - Block name (without .html extension)
250
+ * @returns {Promise<void>}
251
+ * @throws {Error} With code INVALID_NAME or ENOENT when not found
252
+ */
253
+ export async function deleteBlock(name) {
254
+ assertValidName(name);
255
+ try {
256
+ await fs.unlink(blockFilePath(name));
257
+ } catch (err) {
258
+ if (err.code === 'ENOENT') {
259
+ const notFound = new Error('Block not found');
260
+ notFound.code = 'ENOENT';
261
+ throw notFound;
262
+ }
263
+ throw err;
264
+ }
265
+ }
266
+
148
267
  // ---------------------------------------------------------------------------
149
268
  // Public API
150
269
  // ---------------------------------------------------------------------------
@@ -36,7 +36,13 @@ function slugify(str) {
36
36
 
37
37
  async function readSchema(slug) {
38
38
  const raw = await fs.readFile(schemaPath(slug), 'utf8');
39
- return JSON.parse(raw);
39
+ const schema = JSON.parse(raw);
40
+ // Normalise legacy 'preset' flag — both mean "bundled with fresh installs"
41
+ if (schema.preset && !schema.bundled) {
42
+ schema.bundled = true;
43
+ delete schema.preset;
44
+ }
45
+ return schema;
40
46
  }
41
47
 
42
48
  async function writeSchema(schema) {
@@ -115,7 +121,7 @@ export async function getCollection(slug) {
115
121
  * @returns {Promise<object>} Created schema
116
122
  * @throws {Error} If a collection with that slug already exists
117
123
  */
118
- export async function createCollection({title, slug, description = '', fields = [], api = {}, storage}) {
124
+ export async function createCollection({title, slug, description = '', fields = [], api = {}, storage, bundled, plugin}) {
119
125
  await ensureDir();
120
126
  const finalSlug = slug ? slugify(slug) : slugify(title);
121
127
  if (!finalSlug) throw new Error('Could not derive a slug from the title');
@@ -133,6 +139,8 @@ export async function createCollection({title, slug, description = '', fields =
133
139
  slug: finalSlug,
134
140
  title: title.trim(),
135
141
  description: description.trim(),
142
+ ...(bundled ? {bundled: true} : {}),
143
+ ...(plugin ? {plugin} : {}),
136
144
  fields,
137
145
  api: { ...defaultApiAccess(), ...api },
138
146
  storage: storage || {adapter: 'file'},
@@ -157,14 +165,20 @@ export async function updateCollection(slug, updates) {
157
165
  const schema = await getCollection(slug);
158
166
  if (!schema) throw new Error(`Collection "${slug}" not found`);
159
167
 
160
- const { slug: _ignore, createdAt, ...rest } = updates;
168
+ const { slug: _ignore, createdAt, plugin: _plugin, bundled: _bundled, ...rest } = updates;
161
169
  const updated = {
162
170
  ...schema,
163
171
  ...rest,
172
+ // bundled is user-editable — set from update, omit if falsy
173
+ ...(updates.bundled ? {bundled: true} : {}),
174
+ // plugin is ownership metadata — never overwrite from updates
175
+ ...(schema.plugin ? {plugin: schema.plugin} : {}),
164
176
  slug,
165
177
  createdAt: schema.createdAt,
166
178
  updatedAt: new Date().toISOString()
167
179
  };
180
+ // Clear bundled from schema if it was unchecked (schema spread may have preserved old value)
181
+ if (!updates.bundled) delete updated.bundled;
168
182
 
169
183
  await writeSchema(updated);
170
184
 
@@ -90,6 +90,84 @@ export function slugify(str) {
90
90
  return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
91
91
  }
92
92
 
93
+ /**
94
+ * Get a single form definition by slug (alias for readForm).
95
+ *
96
+ * @param {string} slug
97
+ * @returns {Promise<object>}
98
+ * @throws {Error} If the form file does not exist or cannot be parsed.
99
+ */
100
+ export async function getForm(slug) {
101
+ return readForm(slug);
102
+ }
103
+
104
+ /**
105
+ * Create a new form definition and write it to disk.
106
+ *
107
+ * @param {object} data - Form definition data
108
+ * @param {string} data.title - Form title (required if no slug)
109
+ * @param {string} [data.slug] - Explicit slug (derived from title if omitted)
110
+ * @param {string} [data.description]
111
+ * @param {Array} [data.fields]
112
+ * @param {object} [data.settings]
113
+ * @param {object} [data.actions]
114
+ * @returns {Promise<object>} The created form object
115
+ * @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
116
+ */
117
+ export async function createForm(data) {
118
+ const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {}, plugin } = data || {};
119
+
120
+ if (!title?.trim() && !rawSlug?.trim()) {
121
+ throw new Error('A title or slug is required to create a form.');
122
+ }
123
+
124
+ const slug = rawSlug ? slugify(rawSlug) : slugify(title);
125
+ if (!slug) {
126
+ throw new Error('Could not derive a valid slug from the provided title or slug.');
127
+ }
128
+
129
+ // Throw if a form with this slug already exists
130
+ try {
131
+ await readForm(slug);
132
+ const err = new Error(`Form with slug "${slug}" already exists`);
133
+ err.code = 'FORM_ALREADY_EXISTS';
134
+ throw err;
135
+ } catch (err) {
136
+ if (err.code === 'FORM_ALREADY_EXISTS') throw err;
137
+ // File not found — safe to proceed
138
+ }
139
+
140
+ const now = new Date().toISOString();
141
+ const trimmedTitle = title ? title.trim() : slug;
142
+
143
+ const form = {
144
+ slug,
145
+ title: trimmedTitle,
146
+ description,
147
+ ...(plugin ? {plugin} : {}),
148
+ fields: Array.isArray(fields) ? fields : [],
149
+ settings: {
150
+ submitText: 'Submit',
151
+ successMessage: 'Thank you for your submission.',
152
+ layout: 'stacked',
153
+ honeypot: true,
154
+ rateLimitPerMinute: 3,
155
+ ...settings
156
+ },
157
+ actions: {
158
+ email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
159
+ webhook: { enabled: false, url: '', method: 'POST' },
160
+ collection: { enabled: true, slug },
161
+ ...actions
162
+ },
163
+ createdAt: now,
164
+ updatedAt: now
165
+ };
166
+
167
+ await writeForm(slug, form);
168
+ return form;
169
+ }
170
+
93
171
  /** System collections that should never have an auto-generated public form. */
94
172
  const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
95
173