domma-cms 0.8.7 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,7 @@ by [Fastify](https://fastify.dev) on the backend and [Domma](https://npmjs.com/p
17
17
  - [Configuration](#configuration)
18
18
  - [Content](#content)
19
19
  - [Admin Panel](#admin-panel)
20
+ - [Built-in Features](#built-in-features)
20
21
  - [Plugins](#plugins)
21
22
  - [Bundled Plugins](#bundled-plugins)
22
23
  - [Building a Plugin](#building-a-plugin)
@@ -125,9 +126,10 @@ Controls the public-facing site identity.
125
126
  }
126
127
  ```
127
128
 
128
- **Available themes:** `charcoal-dark`, `charcoal-light`, `ocean-dark`, `ocean-light`, `forest-dark`, `forest-light`,
129
- `sunset-dark`, `sunset-light`, `royal-dark`, `royal-light`, `lemon-dark`, `lemon-light`, `silver-dark`, `silver-light`,
130
- `grayve`, `christmas-dark`, `christmas-light`
129
+ **Available themes:** `charcoal-dark`, `charcoal-light`, `christmas-dark`, `christmas-light`, `dreamy-dark`,
130
+ `dreamy-light`, `forest-dark`, `forest-light`, `grayve-dark`, `grayve-light`, `lemon-dark`, `lemon-light`, `mint-dark`,
131
+ `mint-light`, `ocean-dark`, `ocean-light`, `royal-dark`, `royal-light`, `silver-dark`, `silver-light`, `sunset-dark`,
132
+ `sunset-light`, `unicorn-dark`, `unicorn-light`, `wedding-dark`, `wedding-light`
131
133
 
132
134
  ### `config/navigation.json`
133
135
 
@@ -270,18 +272,38 @@ The sidebar groups content by role:
270
272
 
271
273
  ---
272
274
 
275
+ ## Built-in Features
276
+
277
+ These features are part of the CMS core — no plugins required. They are configured via the admin panel under
278
+ **Site Settings** or through `config/site.json`.
279
+
280
+ | Feature | Description |
281
+ |--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
282
+ | **Effects** | Scroll-reveal animations on page elements (`[data-fx]`) and row shortcodes (`[data-reveal]`) via IntersectionObserver |
283
+ | **Celebrations** | Seasonal particle effects that activate automatically by date — Christmas, Halloween, Valentine's, Guy Fawkes, St Patrick's, St Andrew's, St David's, St George's |
284
+ | **Back to Top** | Configurable scroll-to-top button with position, offset, and label options |
285
+ | **Cookie Consent** | GDPR cookie consent banner with per-category toggles (necessary, functional, analytics, marketing) |
286
+ | **Auto Day/Night** | Automatic theme switching between light and dark variants based on time of day |
287
+
288
+ ---
289
+
273
290
  ## Plugins
274
291
 
275
292
  Plugins extend Domma CMS with backend routes, public page injections, and admin panel views.
276
293
 
277
294
  ### Bundled Plugins
278
295
 
279
- | Plugin | Description |
280
- |--------------------|---------------------------------------------------------------------------------------------------|
281
- | **Form Builder** | Visual form buildercreate arbitrary forms, store submissions, trigger email and webhook actions |
282
- | **Analytics** | Basic page view tracking stored as a flat JSON file |
283
- | **Back to Top** | Configurable scroll-to-top button injected into every public page |
284
- | **Cookie Consent** | GDPR cookie consent banner with per-category toggles |
296
+ | Plugin | Description |
297
+ |--------------------|----------------------------------------------------------------------------------|
298
+ | **Analytics** | Basic page view analytics tracks hits per page using a simple JSON store |
299
+ | **Contacts** | Contact manager with groups, favourites, search, and import/export |
300
+ | **Demo Viewer** | Embeds interactive Domma JS demos via iframe shortcode |
301
+ | **Docs** | Document editor with folders, version history, templates, and find & replace |
302
+ | **Garage** | UK vehicle management with DVLA API lookup, save vehicles, and search history |
303
+ | **Notes** | Rich note-taking with categories, search, and markdown content |
304
+ | **Site Search** | Full-text search for the public site — search icon in navbar, Cmd+K shortcut |
305
+ | **Theme Switcher** | Floating disc icon with colour theme dots for switching between all Domma themes |
306
+ | **Todo** | Personal task manager with priorities and status tracking |
285
307
 
286
308
  Enable or disable any plugin in the admin panel under **Plugins**, or directly in `config/plugins.json`.
287
309
 
@@ -7,64 +7,71 @@
7
7
  </div>
8
8
  </div>
9
9
 
10
- <!-- Settings Card -->
11
- <div class="card mb-4">
12
- <div class="card-header"><h2>Settings</h2></div>
13
- <div class="card-body">
14
- <div class="row mb-3">
15
- <div class="col">
16
- <label class="form-check-label">
17
- <input id="field-respect-motion" type="checkbox">
18
- Respect <code>prefers-reduced-motion</code>
19
- </label>
20
- <span class="form-hint">When enabled, JS effects are skipped for users who prefer reduced motion. Content remains visible.</span>
21
- </div>
22
- </div>
23
- <div class="row mb-3">
24
- <div class="col-6">
25
- <label class="form-label">Default animation duration (ms)</label>
26
- <input id="field-default-duration" type="number" class="form-input" min="0" max="10000"
27
- placeholder="600">
28
- <span class="form-hint">Used by reveal when no <code>duration</code> attribute is specified.</span>
29
- </div>
30
- <div class="col-6">
31
- <label class="form-label">Default reveal animation</label>
32
- <select id="field-default-animation" class="form-select">
33
- <option value="fade">Fade</option>
34
- <option value="slide-up">Slide up</option>
35
- <option value="slide-down">Slide down</option>
36
- <option value="zoom">Zoom</option>
37
- <option value="flip">Flip</option>
38
- </select>
39
- </div>
40
- </div>
41
- <div class="row">
42
- <div class="col-6">
43
- <label class="form-label">Default scroll threshold</label>
44
- <input id="field-default-threshold" type="number" class="form-input" min="0" max="1" step="0.05"
45
- placeholder="0.1">
46
- <span class="form-hint">Fraction of element visible before reveal fires (0.0–1.0).</span>
10
+ <!-- Top-level tabs -->
11
+ <div id="effects-tabs" class="tabs">
12
+ <div class="tab-list">
13
+ <button class="tab-item active">Settings</button>
14
+ <button class="tab-item">Shortcode Reference</button>
15
+ </div>
16
+ <div class="tab-content">
17
+
18
+ <!-- Settings tab panel -->
19
+ <div class="tab-panel active">
20
+ <div class="card mb-4">
21
+ <div class="card-body">
22
+ <div class="row mb-3">
23
+ <div class="col">
24
+ <label class="form-check-label">
25
+ <input id="field-respect-motion" type="checkbox">
26
+ Respect <code>prefers-reduced-motion</code>
27
+ </label>
28
+ <span class="form-hint">When enabled, JS effects are skipped for users who prefer reduced motion. Content remains visible.</span>
29
+ </div>
30
+ </div>
31
+ <div class="row mb-3">
32
+ <div class="col-6">
33
+ <label class="form-label">Default animation duration (ms)</label>
34
+ <input id="field-default-duration" type="number" class="form-input" min="0" max="10000"
35
+ placeholder="600">
36
+ <span class="form-hint">Used by reveal when no <code>duration</code> attribute is specified.</span>
37
+ </div>
38
+ <div class="col-6">
39
+ <label class="form-label">Default reveal animation</label>
40
+ <select id="field-default-animation" class="form-select">
41
+ <option value="fade">Fade</option>
42
+ <option value="slide-up">Slide up</option>
43
+ <option value="slide-down">Slide down</option>
44
+ <option value="zoom">Zoom</option>
45
+ <option value="flip">Flip</option>
46
+ </select>
47
+ </div>
48
+ </div>
49
+ <div class="row">
50
+ <div class="col-6">
51
+ <label class="form-label">Default scroll threshold</label>
52
+ <input id="field-default-threshold" type="number" class="form-input" min="0" max="1"
53
+ step="0.05"
54
+ placeholder="0.1">
55
+ <span class="form-hint">Fraction of element visible before reveal fires (0.0–1.0).</span>
56
+ </div>
57
+ </div>
58
+ </div>
47
59
  </div>
48
60
  </div>
49
- </div>
50
- </div>
51
61
 
52
- <!-- Shortcode Reference Card -->
53
- <div class="card mb-4">
54
- <div class="card-header"><h2>Shortcode Reference</h2></div>
55
- <div class="card-body">
62
+ <!-- Shortcode Reference tab panel -->
63
+ <div class="tab-panel">
56
64
  <p class="text-muted mb-3">Use these shortcodes in any page's Markdown content. The <strong>Effects</strong>
57
- toolbar
58
- button in the editor inserts them automatically.</p>
65
+ toolbar button in the editor inserts them automatically.</p>
59
66
 
60
- <!-- Tabs -->
67
+ <!-- Sub-tabs -->
61
68
  <div class="mb-3" style="display:flex;gap:6px;flex-wrap:wrap;">
62
- <button class="btn btn-sm effects-tab-btn active" data-tab="entrance">Entrance</button>
63
- <button class="btn btn-sm effects-tab-btn" data-tab="animation">Animation</button>
64
- <button class="btn btn-sm effects-tab-btn" data-tab="text">Text</button>
65
- <button class="btn btn-sm effects-tab-btn" data-tab="visual">Visual</button>
66
- <button class="btn btn-sm effects-tab-btn" data-tab="examples">Examples</button>
67
- <button class="btn btn-sm effects-tab-btn" data-tab="celebrations">Celebrations</button>
69
+ <button class="btn btn-sm effects-tab-btn active" data-fx-tab="entrance">Entrance</button>
70
+ <button class="btn btn-sm effects-tab-btn" data-fx-tab="animation">Animation</button>
71
+ <button class="btn btn-sm effects-tab-btn" data-fx-tab="text">Text</button>
72
+ <button class="btn btn-sm effects-tab-btn" data-fx-tab="visual">Visual</button>
73
+ <button class="btn btn-sm effects-tab-btn" data-fx-tab="examples">Examples</button>
74
+ <button class="btn btn-sm effects-tab-btn" data-fx-tab="celebrations">Celebrations</button>
68
75
  </div>
69
76
 
70
77
  <!-- Entrance tab -->
@@ -114,9 +121,80 @@ Markdown **works** inside.
114
121
  </tr>
115
122
  </tbody>
116
123
  </table>
117
- <p class="text-muted" style="font-size:.85rem;"><strong>Tip:</strong> Use <code>delay</code> to stagger
124
+ <p class="text-muted mb-3" style="font-size:.85rem;"><strong>Tip:</strong> Use <code>delay</code> to stagger
118
125
  multiple
119
- reveal blocks for a cascade effect.</p>
126
+ reveal blocks for a cascade effect, or use <code>[row reveal]</code> below for automatic staggering.</p>
127
+
128
+ <h3 class="mb-2">Row Reveal</h3>
129
+ <p class="text-muted mb-2">Add <code>reveal</code> to a <code>[row]</code> shortcode to automatically
130
+ animate
131
+ child columns into view one by one as the row scrolls into the viewport. No need to wrap each column in
132
+ a
133
+ separate <code>[reveal]</code> block.</p>
134
+ <pre class="code-block mb-2">[row gap="4" reveal reveal-mode="stagger" reveal-animation="slide-up"]
135
+ [col]First to appear[/col]
136
+ [col]Second to appear[/col]
137
+ [col]Third to appear[/col]
138
+ [/row]</pre>
139
+ <table class="table mb-3">
140
+ <thead>
141
+ <tr>
142
+ <th>Attribute</th>
143
+ <th>Default</th>
144
+ <th>Description</th>
145
+ </tr>
146
+ </thead>
147
+ <tbody>
148
+ <tr>
149
+ <td><code>reveal</code></td>
150
+ <td>—</td>
151
+ <td>Flag — enables scroll-triggered reveal on child columns</td>
152
+ </tr>
153
+ <tr>
154
+ <td><code>reveal-animation</code></td>
155
+ <td>slide-up</td>
156
+ <td>slide-up, slide-down, slide-left, slide-right, fade, zoom, flip</td>
157
+ </tr>
158
+ <tr>
159
+ <td><code>reveal-mode</code></td>
160
+ <td>stagger</td>
161
+ <td>stagger (overlapping) or sequence (one after another)</td>
162
+ </tr>
163
+ <tr>
164
+ <td><code>reveal-duration</code></td>
165
+ <td>400</td>
166
+ <td>Animation duration in milliseconds</td>
167
+ </tr>
168
+ <tr>
169
+ <td><code>reveal-stagger</code></td>
170
+ <td>60</td>
171
+ <td>Delay between each child column (ms)</td>
172
+ </tr>
173
+ <tr>
174
+ <td><code>reveal-delay</code></td>
175
+ <td>0</td>
176
+ <td>Initial delay before first animation (ms)</td>
177
+ </tr>
178
+ <tr>
179
+ <td><code>reveal-direction</code></td>
180
+ <td>ltr</td>
181
+ <td>ltr (left to right) or rtl (right to left)</td>
182
+ </tr>
183
+ </tbody>
184
+ </table>
185
+
186
+ <h4 class="mb-1" style="font-size:.9rem;">Fade with right-to-left direction</h4>
187
+ <pre class="code-block mb-2">[row gap="3" reveal reveal-animation="fade" reveal-direction="rtl"]
188
+ [col]Appears third[/col]
189
+ [col]Appears second[/col]
190
+ [col]Appears first[/col]
191
+ [/row]</pre>
192
+
193
+ <h4 class="mb-1" style="font-size:.9rem;">Zoom with initial delay</h4>
194
+ <pre class="code-block mb-2">[row gap="4" reveal reveal-animation="zoom" reveal-delay="200" reveal-stagger="100"]
195
+ [col]Zooms in after 200ms[/col]
196
+ [col]Zooms in after 300ms[/col]
197
+ [/row]</pre>
120
198
  </div>
121
199
 
122
200
  <!-- Animation tab -->
@@ -142,9 +220,7 @@ This will shake.
142
220
  [/shake]</pre>
143
221
 
144
222
  <h3 class="mb-2">CSS Animate</h3>
145
- <p class="text-muted mb-2">Applies Domma CSS animation utility classes — no JavaScript required. Works
146
- without the
147
- plugin enabled.</p>
223
+ <p class="text-muted mb-2">Applies Domma CSS animation utility classes — no JavaScript required.</p>
148
224
  <pre class="code-block mb-2">[animate type="fade-in-up" duration="normal" delay="200" repeat="once"]
149
225
  Content here.
150
226
  [/animate]</pre>
@@ -358,8 +434,7 @@ Content beneath the particles.
358
434
  [/twinkle]</pre>
359
435
 
360
436
  <h3 class="mb-2">Ambient Background</h3>
361
- <p class="text-muted mb-2">Applies animated CSS background classes — no JavaScript needed. Works without the
362
- plugin.</p>
437
+ <p class="text-muted mb-2">Applies animated CSS background classes — no JavaScript needed.</p>
363
438
  <pre class="code-block mb-2">[ambient type="float-blobs" speed="slow" intensity="subtle"]
364
439
  Content on animated background.
365
440
  [/ambient]</pre>
@@ -420,17 +495,17 @@ Satisfaction
420
495
  [/reveal]</pre>
421
496
 
422
497
  <h3 class="mb-2">Staggered card reveal</h3>
423
- <pre class="code-block mb-3">[reveal animation="fade-in-up" delay="0"]
498
+ <pre class="code-block mb-3">[row gap="4" reveal reveal-animation="slide-up" reveal-stagger="100"]
499
+ [col]
424
500
  [card title="Feature One"]First card content.[/card]
425
- [/reveal]
426
-
427
- [reveal animation="fade-in-up" delay="150"]
501
+ [/col]
502
+ [col]
428
503
  [card title="Feature Two"]Second card content.[/card]
429
- [/reveal]
430
-
431
- [reveal animation="fade-in-up" delay="300"]
504
+ [/col]
505
+ [col]
432
506
  [card title="Feature Three"]Third card content.[/card]
433
- [/reveal]</pre>
507
+ [/col]
508
+ [/row]</pre>
434
509
 
435
510
  <h3 class="mb-2">Hero with ambient background</h3>
436
511
  <pre class="code-block mb-3">[ambient type="aurora" speed="slow" intensity="subtle"]
@@ -509,10 +584,9 @@ Click me for a burst!
509
584
  [firework type="trail" colour="warning" /]
510
585
  [/fireworks]</pre>
511
586
 
512
- <h3 class="mb-2">Celebrate <span class="badge badge-warning">Requires plugin JS</span></h3>
587
+ <h3 class="mb-2">Celebrate <span class="badge badge-info">JS canvas</span></h3>
513
588
  <p class="text-muted mb-2">Canvas-based seasonal particle system. Auto-detects the active celebration based
514
- on
515
- today's date, or specify a theme manually. Skipped automatically when
589
+ on today's date, or specify a theme manually. Skipped automatically when
516
590
  <code>prefers-reduced-motion</code> is active.</p>
517
591
  <pre class="code-block mb-2">[celebrate theme="auto" intensity="medium" /]</pre>
518
592
  <pre class="code-block mb-2">[celebrate theme="christmas" intensity="heavy" /]</pre>
@@ -590,5 +664,6 @@ Click me for a burst!
590
664
  Canvas celebrations (<code>[celebrate]</code>) load the JS module on demand and degrade silently if
591
665
  unavailable.</p>
592
666
  </div>
593
- </div>
594
- </div>
667
+ </div><!-- /tab-panel shortcodes -->
668
+ </div><!-- /tab-content -->
669
+ </div><!-- /effects-tabs -->
@@ -1 +1 @@
1
- import{apiRequest as s}from"/admin/js/api.js";export const effectsView={templateUrl:"/admin/js/templates/effects.html",async onMount(t){let e={};try{e=await s("/effects/settings")}catch{E.toast("Could not load settings.",{type:"error"})}t.find("#field-respect-motion").prop("checked",e.respectMotion!==!1),t.find("#field-default-duration").val(e.defaultDuration??600),t.find("#field-default-animation").val(e.defaultAnimation||"fade"),t.find("#field-default-threshold").val(e.defaultThreshold??.1),t.find("#save-settings-btn").off("click").on("click",async()=>{const a={respectMotion:t.find("#field-respect-motion").prop("checked"),defaultDuration:parseInt(t.find("#field-default-duration").val(),10)||600,defaultAnimation:t.find("#field-default-animation").val(),defaultThreshold:parseFloat(t.find("#field-default-threshold").val())||.1};try{await s("/effects/settings",{method:"PUT",body:JSON.stringify(a)}),E.toast("Settings saved.",{type:"success"})}catch{E.toast("Failed to save settings.",{type:"error"})}}),t.find(".effects-tab-btn").on("click",function(){const a=$(this).data("tab");t.find(".effects-tab-btn").removeClass("active"),$(this).addClass("active"),t.find(".effects-tab-panel").hide(),t.find(`#tab-${a}`).show()}),t.find(".effects-tab-panel").hide(),t.find("#tab-entrance").show(),Domma.icons.scan()}};
1
+ import{apiRequest as a}from"/admin/js/api.js";export const effectsView={templateUrl:"/admin/js/templates/effects.html",async onMount(t){let e={};try{e=await a("/effects/settings")}catch{E.toast("Could not load settings.",{type:"error"})}t.find("#field-respect-motion").prop("checked",e.respectMotion!==!1),t.find("#field-default-duration").val(e.defaultDuration??600),t.find("#field-default-animation").val(e.defaultAnimation||"fade"),t.find("#field-default-threshold").val(e.defaultThreshold??.1),t.find("#save-settings-btn").off("click").on("click",async()=>{const s={respectMotion:t.find("#field-respect-motion").prop("checked"),defaultDuration:parseInt(t.find("#field-default-duration").val(),10)||600,defaultAnimation:t.find("#field-default-animation").val(),defaultThreshold:parseFloat(t.find("#field-default-threshold").val())||.1};try{await a("/effects/settings",{method:"PUT",body:JSON.stringify(s)}),E.toast("Settings saved.",{type:"success"})}catch{E.toast("Failed to save settings.",{type:"error"})}}),E.tabs(t.find("#effects-tabs").get(0)),t.find(".effects-tab-btn").on("click",function(){const s=this.getAttribute("data-fx-tab");t.find(".effects-tab-btn").removeClass("active"),$(this).addClass("active"),t.find(".effects-tab-panel").hide(),t.find(`#tab-${s}`).show()}),t.find(".effects-tab-panel").hide(),t.find("#tab-entrance").show(),Domma.icons.scan()}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.8.7",
3
+ "version": "0.8.10",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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
37
  const {content} = 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);
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,108 @@ 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
+ blocks.push({
184
+ name,
185
+ size: fileStat?.size ?? 0,
186
+ updatedAt: fileStat?.mtime?.toISOString() ?? null,
187
+ });
188
+ }
189
+ return blocks.sort((a, b) => a.name.localeCompare(b.name));
190
+ }
191
+
192
+ /**
193
+ * Read a single block's content by name.
194
+ *
195
+ * @param {string} name - Block name (without .html extension)
196
+ * @returns {Promise<{name: string, content: string}>}
197
+ * @throws {Error} With code INVALID_NAME or ENOENT when not found
198
+ */
199
+ export async function getBlock(name) {
200
+ assertValidName(name);
201
+ try {
202
+ const content = await fs.readFile(blockFilePath(name), 'utf8');
203
+ return {name, content};
204
+ } catch (err) {
205
+ if (err.code === 'ENOENT') {
206
+ const notFound = new Error('Block not found');
207
+ notFound.code = 'ENOENT';
208
+ throw notFound;
209
+ }
210
+ throw err;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Create or update a block file (upsert).
216
+ *
217
+ * @param {string} name - Block name (without .html extension)
218
+ * @param {string} content - HTML template content
219
+ * @returns {Promise<{success: boolean, name: string}>}
220
+ * @throws {Error} With code INVALID_NAME on bad name
221
+ */
222
+ export async function saveBlock(name, content) {
223
+ assertValidName(name);
224
+ await fs.mkdir(BLOCKS_DIR, {recursive: true});
225
+ await fs.writeFile(blockFilePath(name), content, 'utf8');
226
+ return {success: true, name};
227
+ }
228
+
229
+ /**
230
+ * Delete a block file.
231
+ *
232
+ * @param {string} name - Block name (without .html extension)
233
+ * @returns {Promise<void>}
234
+ * @throws {Error} With code INVALID_NAME or ENOENT when not found
235
+ */
236
+ export async function deleteBlock(name) {
237
+ assertValidName(name);
238
+ try {
239
+ await fs.unlink(blockFilePath(name));
240
+ } catch (err) {
241
+ if (err.code === 'ENOENT') {
242
+ const notFound = new Error('Block not found');
243
+ notFound.code = 'ENOENT';
244
+ throw notFound;
245
+ }
246
+ throw err;
247
+ }
248
+ }
249
+
148
250
  // ---------------------------------------------------------------------------
149
251
  // Public API
150
252
  // ---------------------------------------------------------------------------
@@ -90,6 +90,83 @@ 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 = {} } = 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
+ fields: Array.isArray(fields) ? fields : [],
148
+ settings: {
149
+ submitText: 'Submit',
150
+ successMessage: 'Thank you for your submission.',
151
+ layout: 'stacked',
152
+ honeypot: true,
153
+ rateLimitPerMinute: 3,
154
+ ...settings
155
+ },
156
+ actions: {
157
+ email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
158
+ webhook: { enabled: false, url: '', method: 'POST' },
159
+ collection: { enabled: true, slug },
160
+ ...actions
161
+ },
162
+ createdAt: now,
163
+ updatedAt: now
164
+ };
165
+
166
+ await writeForm(slug, form);
167
+ return form;
168
+ }
169
+
93
170
  /** System collections that should never have an auto-generated public form. */
94
171
  const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
95
172
 
@@ -250,12 +250,42 @@ export async function runLifecycleHook(name, hook, fastify) {
250
250
  const mod = await import(pluginJsPath);
251
251
  if (typeof mod[hook] !== 'function') return;
252
252
 
253
- const [collections, roles] = await Promise.all([
253
+ // Use Promise.allSettled to prevent total failure if any service import rejects
254
+ // (e.g. MongoDB-dependent modules like actions.js or views.js)
255
+ const results = await Promise.allSettled([
254
256
  import(path.resolve('server/services/collections.js')),
257
+ import(path.resolve('server/services/forms.js')),
258
+ import(path.resolve('server/services/blocks.js')),
259
+ import(path.resolve('server/services/content.js')),
260
+ import(path.resolve('server/config.js')),
255
261
  import(path.resolve('server/services/roles.js')),
262
+ import(path.resolve('server/services/users.js')),
263
+ import(path.resolve('server/services/actions.js')),
264
+ import(path.resolve('server/services/views.js')),
256
265
  ]);
257
266
 
258
- await mod[hook]({ fastify, services: { collections, roles } });
267
+ const [collectionsResult, formsResult, blocksResult, contentResult, configResult, rolesResult, usersResult, actionsResult, viewsResult] = results;
268
+
269
+ const services = {
270
+ collections: collectionsResult.status === 'fulfilled' ? collectionsResult.value : null,
271
+ forms: formsResult.status === 'fulfilled' ? formsResult.value : null,
272
+ blocks: blocksResult.status === 'fulfilled' ? blocksResult.value : null,
273
+ content: contentResult.status === 'fulfilled' ? contentResult.value : null,
274
+ config: configResult.status === 'fulfilled' ? configResult.value : null,
275
+ roles: rolesResult.status === 'fulfilled' ? rolesResult.value : null,
276
+ users: usersResult.status === 'fulfilled' ? usersResult.value : null,
277
+ actions: actionsResult.status === 'fulfilled' ? actionsResult.value : null,
278
+ views: viewsResult.status === 'fulfilled' ? viewsResult.value : null,
279
+ };
280
+
281
+ // Log any import failures as warnings (not errors)
282
+ results.forEach((result, i) => {
283
+ if (result.status === 'rejected') {
284
+ fastify.log.warn({ err: result.reason }, `[plugins] Failed to load service ${i} for lifecycle hook`);
285
+ }
286
+ });
287
+
288
+ await mod[hook]({ fastify, services });
259
289
  } catch (err) {
260
290
  fastify.log.error(`Plugin "${name}" lifecycle hook "${hook}" failed: ${err.message}`);
261
291
  }