domma-cms 0.1.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +469 -0
  3. package/admin/css/admin.css +1123 -0
  4. package/admin/index.html +72 -0
  5. package/admin/js/api.js +210 -0
  6. package/admin/js/app.js +270 -0
  7. package/admin/js/config/sidebar-config.js +107 -0
  8. package/admin/js/lib/card.js +63 -0
  9. package/admin/js/lib/image-editor.js +869 -0
  10. package/admin/js/lib/markdown-toolbar.js +421 -0
  11. package/admin/js/templates/dashboard.html +50 -0
  12. package/admin/js/templates/documentation.html +237 -0
  13. package/admin/js/templates/layouts.html +11 -0
  14. package/admin/js/templates/login.html +58 -0
  15. package/admin/js/templates/media.html +16 -0
  16. package/admin/js/templates/navigation.html +50 -0
  17. package/admin/js/templates/page-editor.html +126 -0
  18. package/admin/js/templates/pages.html +18 -0
  19. package/admin/js/templates/plugins.html +12 -0
  20. package/admin/js/templates/settings.html +190 -0
  21. package/admin/js/templates/tutorials.html +233 -0
  22. package/admin/js/templates/user-editor.html +12 -0
  23. package/admin/js/templates/users.html +10 -0
  24. package/admin/js/views/dashboard.js +48 -0
  25. package/admin/js/views/documentation.js +12 -0
  26. package/admin/js/views/index.js +33 -0
  27. package/admin/js/views/layouts.js +49 -0
  28. package/admin/js/views/login.js +254 -0
  29. package/admin/js/views/media.js +240 -0
  30. package/admin/js/views/navigation.js +152 -0
  31. package/admin/js/views/page-editor.js +479 -0
  32. package/admin/js/views/pages.js +64 -0
  33. package/admin/js/views/plugins.js +100 -0
  34. package/admin/js/views/settings.js +64 -0
  35. package/admin/js/views/tutorials.js +12 -0
  36. package/admin/js/views/user-editor.js +88 -0
  37. package/admin/js/views/users.js +73 -0
  38. package/bin/cli.js +334 -0
  39. package/config/auth.json +20 -0
  40. package/config/content.json +10 -0
  41. package/config/navigation.json +63 -0
  42. package/config/plugins.json +47 -0
  43. package/config/presets.json +34 -0
  44. package/config/server.json +6 -0
  45. package/config/site.json +33 -0
  46. package/package.json +67 -0
  47. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
  48. package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
  49. package/plugins/back-to-top/config.js +10 -0
  50. package/plugins/back-to-top/plugin.js +24 -0
  51. package/plugins/back-to-top/plugin.json +36 -0
  52. package/plugins/back-to-top/public/inject-body.html +105 -0
  53. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
  54. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
  55. package/plugins/cookie-consent/config.js +30 -0
  56. package/plugins/cookie-consent/plugin.js +24 -0
  57. package/plugins/cookie-consent/plugin.json +36 -0
  58. package/plugins/cookie-consent/public/inject-body.html +69 -0
  59. package/plugins/custom-css/admin/templates/custom-css.html +17 -0
  60. package/plugins/custom-css/admin/views/custom-css.js +35 -0
  61. package/plugins/custom-css/config.js +1 -0
  62. package/plugins/custom-css/data/custom.css +0 -0
  63. package/plugins/custom-css/plugin.js +63 -0
  64. package/plugins/custom-css/plugin.json +32 -0
  65. package/plugins/custom-css/public/inject-head.html +1 -0
  66. package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
  67. package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
  68. package/plugins/domma-effects/config.js +9 -0
  69. package/plugins/domma-effects/plugin.js +22 -0
  70. package/plugins/domma-effects/plugin.json +36 -0
  71. package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
  72. package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
  73. package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
  74. package/plugins/domma-effects/public/celebrations/index.js +535 -0
  75. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
  76. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
  77. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
  78. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
  79. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
  80. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
  81. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
  82. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
  83. package/plugins/domma-effects/public/inject-body.html +268 -0
  84. package/plugins/example-analytics/admin/templates/analytics.html +10 -0
  85. package/plugins/example-analytics/admin/views/analytics.js +51 -0
  86. package/plugins/example-analytics/config.js +6 -0
  87. package/plugins/example-analytics/plugin.js +58 -0
  88. package/plugins/example-analytics/plugin.json +27 -0
  89. package/plugins/example-analytics/public/inject-body.html +13 -0
  90. package/plugins/example-analytics/public/inject-head.html +1 -0
  91. package/plugins/example-analytics/stats.json +1 -0
  92. package/plugins/form-builder/admin/templates/form-editor.html +158 -0
  93. package/plugins/form-builder/admin/templates/form-settings.html +29 -0
  94. package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
  95. package/plugins/form-builder/admin/templates/forms-list.html +17 -0
  96. package/plugins/form-builder/admin/views/form-editor.js +817 -0
  97. package/plugins/form-builder/admin/views/form-settings.js +38 -0
  98. package/plugins/form-builder/admin/views/form-submissions.js +295 -0
  99. package/plugins/form-builder/admin/views/forms-list.js +164 -0
  100. package/plugins/form-builder/config.js +9 -0
  101. package/plugins/form-builder/data/forms/contact-details.json +63 -0
  102. package/plugins/form-builder/data/forms/contact.json +52 -0
  103. package/plugins/form-builder/data/submissions/contact-details.json +1 -0
  104. package/plugins/form-builder/data/submissions/contact.json +14 -0
  105. package/plugins/form-builder/email.js +103 -0
  106. package/plugins/form-builder/plugin.js +454 -0
  107. package/plugins/form-builder/plugin.json +56 -0
  108. package/plugins/form-builder/public/inject-body.html +270 -0
  109. package/plugins/form-builder/public/inject-head.html +42 -0
  110. package/public/css/site.css +189 -0
  111. package/public/js/site.js +109 -0
  112. package/scripts/copy-domma.js +48 -0
  113. package/scripts/fresh.js +41 -0
  114. package/scripts/reset.js +124 -0
  115. package/scripts/seed.js +666 -0
  116. package/scripts/setup.js +263 -0
  117. package/server/config.js +56 -0
  118. package/server/middleware/auth.js +97 -0
  119. package/server/routes/api/auth.js +116 -0
  120. package/server/routes/api/layouts.js +25 -0
  121. package/server/routes/api/media.js +93 -0
  122. package/server/routes/api/navigation.js +37 -0
  123. package/server/routes/api/pages.js +118 -0
  124. package/server/routes/api/plugins.js +46 -0
  125. package/server/routes/api/settings.js +25 -0
  126. package/server/routes/api/users.js +110 -0
  127. package/server/routes/public.js +108 -0
  128. package/server/server.js +169 -0
  129. package/server/services/content.js +298 -0
  130. package/server/services/images.js +334 -0
  131. package/server/services/markdown.js +297 -0
  132. package/server/services/plugins.js +246 -0
  133. package/server/services/renderer.js +80 -0
  134. package/server/services/users.js +212 -0
  135. package/server/templates/page.html +78 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Content Service
3
+ * File-based CRUD for pages and media.
4
+ * Pages live in content/pages/ mirroring URL structure.
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import {parseMarkdown, serialiseMarkdown} from './markdown.js';
9
+ import {config} from '../config.js';
10
+
11
+ const CONTENT_DIR = config.content.contentDir;
12
+ const PAGES_DIR = path.join(CONTENT_DIR, 'pages');
13
+ const MEDIA_DIR = config.content.mediaDir;
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Pages
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Recursively list all .md files under PAGES_DIR.
21
+ * Returns parsed page objects sorted by sortOrder then title.
22
+ *
23
+ * @returns {Promise<object[]>}
24
+ */
25
+ export async function listPages() {
26
+ const files = await collectMdFiles(PAGES_DIR);
27
+ const pages = await Promise.all(files.map(filePath => readPageFile(filePath)));
28
+ return pages.sort((a, b) => (a.sortOrder ?? 99) - (b.sortOrder ?? 99) || a.title.localeCompare(b.title));
29
+ }
30
+
31
+ /**
32
+ * Get a single page by its URL path (e.g. '/about' or '/services/web-dev').
33
+ * The empty string / '/' resolves to index.md.
34
+ *
35
+ * @param {string} urlPath
36
+ * @returns {Promise<object|null>}
37
+ */
38
+ export async function getPage(urlPath) {
39
+ const filePath = await resolveExistingFilePath(urlPath);
40
+ try {
41
+ return await readPageFile(filePath);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Create a new page. Auto-creates parent directories.
49
+ *
50
+ * @param {string} urlPath
51
+ * @param {object} frontmatter
52
+ * @param {string} body
53
+ * @returns {Promise<object>}
54
+ */
55
+ export async function createPage(urlPath, frontmatter, body) {
56
+ const filePath = urlPathToFilePath(urlPath);
57
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
58
+
59
+ const defaults = config.content.pageDefaults;
60
+ const now = new Date().toISOString();
61
+ const meta = {
62
+ title: frontmatter.title || 'Untitled',
63
+ slug: slugFromPath(urlPath),
64
+ description: frontmatter.description || '',
65
+ layout: frontmatter.layout || defaults.layout,
66
+ status: frontmatter.status || defaults.status,
67
+ sortOrder: frontmatter.sortOrder ?? defaults.sortOrder,
68
+ showInNav: frontmatter.showInNav ?? false,
69
+ sidebar: frontmatter.sidebar ?? false,
70
+ seo: frontmatter.seo || {},
71
+ createdAt: now,
72
+ updatedAt: now
73
+ };
74
+
75
+ await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
76
+ return readPageFile(filePath);
77
+ }
78
+
79
+ /**
80
+ * Update an existing page.
81
+ *
82
+ * @param {string} urlPath
83
+ * @param {object} frontmatter
84
+ * @param {string} body
85
+ * @returns {Promise<object>}
86
+ */
87
+ export async function updatePage(urlPath, frontmatter, body) {
88
+ const filePath = await resolveExistingFilePath(urlPath);
89
+ const existing = await readPageFile(filePath);
90
+ const { urlPath: _u, content: existingContent, html: _h, ...existingMeta } = existing;
91
+
92
+ const meta = {
93
+ ...existingMeta,
94
+ ...frontmatter,
95
+ updatedAt: new Date().toISOString()
96
+ };
97
+
98
+ if (existingMeta.createdAt) meta.createdAt = existingMeta.createdAt;
99
+
100
+ await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
101
+ return readPageFile(filePath);
102
+ }
103
+
104
+ /**
105
+ * Rename (move) a page to a new URL path.
106
+ * Creates any required parent directories for the destination.
107
+ *
108
+ * @param {string} oldUrlPath
109
+ * @param {string} newUrlPath
110
+ * @returns {Promise<void>}
111
+ */
112
+ export async function renamePage(oldUrlPath, newUrlPath) {
113
+ const oldFile = await resolveExistingFilePath(oldUrlPath);
114
+ const newFile = urlPathToFilePath(newUrlPath);
115
+ await fs.mkdir(path.dirname(newFile), { recursive: true });
116
+ await fs.rename(oldFile, newFile);
117
+ }
118
+
119
+ /**
120
+ * Delete a page file.
121
+ *
122
+ * @param {string} urlPath
123
+ * @returns {Promise<void>}
124
+ */
125
+ export async function deletePage(urlPath) {
126
+ const filePath = await resolveExistingFilePath(urlPath);
127
+ await fs.unlink(filePath);
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Media
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * List all files in the media directory.
136
+ *
137
+ * @returns {Promise<object[]>}
138
+ */
139
+ export async function listMedia() {
140
+ await fs.mkdir(MEDIA_DIR, { recursive: true });
141
+ const files = await fs.readdir(MEDIA_DIR);
142
+ const stats = await Promise.all(
143
+ files.map(async (file) => {
144
+ const filePath = path.join(MEDIA_DIR, file);
145
+ const stat = await fs.stat(filePath);
146
+ return {
147
+ name: file,
148
+ url: `/media/${file}`,
149
+ size: stat.size,
150
+ createdAt: stat.birthtime.toISOString()
151
+ };
152
+ })
153
+ );
154
+ return stats.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
155
+ }
156
+
157
+ /**
158
+ * Save an uploaded file to the media directory.
159
+ *
160
+ * @param {string} filename
161
+ * @param {Buffer} buffer
162
+ * @returns {Promise<object>}
163
+ */
164
+ export async function saveMedia(filename, buffer) {
165
+ await fs.mkdir(MEDIA_DIR, { recursive: true });
166
+ const filePath = path.join(MEDIA_DIR, filename);
167
+ await fs.writeFile(filePath, buffer);
168
+ return { name: filename, url: `/media/${filename}` };
169
+ }
170
+
171
+ /**
172
+ * Delete a media file.
173
+ *
174
+ * @param {string} filename
175
+ * @returns {Promise<void>}
176
+ */
177
+ export async function deleteMedia(filename) {
178
+ const filePath = path.join(MEDIA_DIR, filename);
179
+ await fs.unlink(filePath);
180
+ }
181
+
182
+ /**
183
+ * Rename a media file.
184
+ *
185
+ * @param {string} oldName - Current filename
186
+ * @param {string} newName - Desired filename
187
+ * @returns {Promise<{ name: string, url: string }>}
188
+ * @throws {Error} If the destination already exists
189
+ */
190
+ export async function renameMedia(oldName, newName) {
191
+ const srcPath = path.join(MEDIA_DIR, oldName);
192
+ const destPath = path.join(MEDIA_DIR, newName);
193
+
194
+ try {
195
+ await fs.access(destPath);
196
+ throw new Error(`A file named "${newName}" already exists.`);
197
+ } catch (err) {
198
+ if (err.code !== 'ENOENT') throw err;
199
+ }
200
+
201
+ await fs.rename(srcPath, destPath);
202
+ return {name: newName, url: `/media/${newName}`};
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Helpers
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * Resolve the actual file path for an existing page.
211
+ * Tries the direct file first (e.g. about.md), then the directory index (e.g. blog/index.md).
212
+ *
213
+ * @param {string} urlPath
214
+ * @returns {Promise<string>}
215
+ */
216
+ async function resolveExistingFilePath(urlPath) {
217
+ const directFile = urlPathToFilePath(urlPath);
218
+ try {
219
+ await fs.access(directFile);
220
+ return directFile;
221
+ } catch {
222
+ const normalised = urlPath === '/' || !urlPath ? 'index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
223
+ return path.join(PAGES_DIR, normalised, 'index.md');
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Recursively collect all .md file paths under a directory.
229
+ *
230
+ * @param {string} dir
231
+ * @returns {Promise<string[]>}
232
+ */
233
+ async function collectMdFiles(dir) {
234
+ let results = [];
235
+ let entries;
236
+ try {
237
+ entries = await fs.readdir(dir, { withFileTypes: true });
238
+ } catch {
239
+ return results;
240
+ }
241
+ for (const entry of entries) {
242
+ const full = path.join(dir, entry.name);
243
+ if (entry.isDirectory()) {
244
+ results = results.concat(await collectMdFiles(full));
245
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
246
+ results.push(full);
247
+ }
248
+ }
249
+ return results;
250
+ }
251
+
252
+ /**
253
+ * Read and parse a single .md file, injecting the derived urlPath.
254
+ *
255
+ * @param {string} filePath
256
+ * @returns {Promise<object>}
257
+ */
258
+ async function readPageFile(filePath) {
259
+ const raw = await fs.readFile(filePath, 'utf8');
260
+ const { data, content, html } = parseMarkdown(raw);
261
+ const urlPath = filePathToUrlPath(filePath);
262
+ return { ...data, urlPath, content, html };
263
+ }
264
+
265
+ /**
266
+ * Derive the URL path from a file path.
267
+ * e.g. content/pages/services/web-dev.md → /services/web-dev
268
+ * content/pages/index.md → /
269
+ * content/pages/services/index.md → /services
270
+ */
271
+ function filePathToUrlPath(filePath) {
272
+ const relative = path.relative(PAGES_DIR, filePath);
273
+ const withoutExt = relative.replace(/\.md$/, '');
274
+ if (withoutExt === 'index') return '/';
275
+ const parts = withoutExt.split(path.sep);
276
+ if (parts[parts.length - 1] === 'index') parts.pop();
277
+ return '/' + parts.join('/');
278
+ }
279
+
280
+ /**
281
+ * Derive the file path from a URL path.
282
+ * e.g. / → content/pages/index.md
283
+ * /about → content/pages/about.md
284
+ * /services → content/pages/services/index.md (checked second)
285
+ */
286
+ function urlPathToFilePath(urlPath) {
287
+ const normalised = urlPath === '/' || !urlPath ? 'index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
288
+ return path.join(PAGES_DIR, normalised + '.md');
289
+ }
290
+
291
+ /**
292
+ * Extract a slug from a URL path.
293
+ */
294
+ function slugFromPath(urlPath) {
295
+ if (urlPath === '/' || !urlPath) return 'index';
296
+ const parts = urlPath.split('/').filter(Boolean);
297
+ return parts[parts.length - 1] || 'index';
298
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Image Service
3
+ * Sharp-based image processing for the media library.
4
+ * Supports crop, resize, rotate, flip, colour presets, adjustments,
5
+ * watermark compositing, border/padding, and format conversion.
6
+ */
7
+ import fs from 'fs/promises';
8
+ import path from 'path';
9
+ import sharp from 'sharp';
10
+ import {config} from '../config.js';
11
+
12
+ const MEDIA_DIR = config.content.mediaDir;
13
+
14
+ const EDITABLE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.tiff']);
15
+
16
+ /**
17
+ * Check whether a filename is an editable image type.
18
+ *
19
+ * @param {string} filename
20
+ * @returns {boolean}
21
+ */
22
+ export function isEditableImage(filename) {
23
+ const ext = path.extname(filename).toLowerCase();
24
+ return EDITABLE_EXTENSIONS.has(ext);
25
+ }
26
+
27
+ /**
28
+ * Get image dimensions and format from a media file.
29
+ *
30
+ * @param {string} filename
31
+ * @returns {Promise<{ width: number, height: number, format: string }>}
32
+ * @throws {Error} If file not found or unreadable
33
+ */
34
+ export async function getImageInfo(filename) {
35
+ const filePath = path.join(MEDIA_DIR, filename);
36
+ const {width, height, format} = await sharp(filePath).metadata();
37
+ return {width, height, format};
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Pipeline helpers (private)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Parse a hex colour string into an {r,g,b} object.
46
+ *
47
+ * @param {string} hex e.g. '#ff0000' or 'ff0000'
48
+ * @returns {{ r: number, g: number, b: number }}
49
+ */
50
+ function parseHexColour(hex) {
51
+ const clean = hex.replace('#', '');
52
+ return {
53
+ r: parseInt(clean.slice(0, 2), 16),
54
+ g: parseInt(clean.slice(2, 4), 16),
55
+ b: parseInt(clean.slice(4, 6), 16),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Apply a named colour preset to the pipeline.
61
+ * 'sharpen' and 'soften' are skipped when the matching adjustment is set,
62
+ * avoiding a double-application.
63
+ *
64
+ * @param {import('sharp').Sharp} pipeline
65
+ * @param {string} preset
66
+ * @param {object} [adjustments]
67
+ * @returns {import('sharp').Sharp}
68
+ */
69
+ function applyPreset(pipeline, preset, adjustments = {}) {
70
+ switch (preset) {
71
+ case 'grayscale':
72
+ return pipeline.greyscale();
73
+ case 'sepia':
74
+ return pipeline.greyscale().tint({r: 112, g: 66, b: 20});
75
+ case 'warm':
76
+ return pipeline.modulate({hue: 30}).tint({r: 255, g: 220, b: 180});
77
+ case 'cool':
78
+ return pipeline.modulate({hue: 210}).tint({r: 180, g: 210, b: 255});
79
+ case 'enhance':
80
+ return pipeline.normalise();
81
+ case 'sharpen':
82
+ return adjustments.sharpen > 0 ? pipeline : pipeline.sharpen({sigma: 2});
83
+ case 'soften':
84
+ return adjustments.blur > 0 ? pipeline : pipeline.blur(2);
85
+ case 'invert':
86
+ return pipeline.negate();
87
+ default:
88
+ return pipeline;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Apply numeric adjustment sliders to the pipeline.
94
+ * Only values that differ from defaults trigger Sharp calls.
95
+ *
96
+ * @param {import('sharp').Sharp} pipeline
97
+ * @param {object} adj
98
+ * @param {number} [adj.brightness] 1.0 = no change
99
+ * @param {number} [adj.saturation] 1.0 = no change
100
+ * @param {number} [adj.contrast] 1.0 = no change
101
+ * @param {number} [adj.blur] sigma (> 0.3 to apply)
102
+ * @param {number} [adj.sharpen] sigma
103
+ * @param {number} [adj.gamma] 1.0 = no change
104
+ * @returns {import('sharp').Sharp}
105
+ */
106
+ function applyAdjustments(pipeline, adj) {
107
+ if (!adj) return pipeline;
108
+
109
+ const modulate = {};
110
+ if (adj.brightness !== undefined && adj.brightness !== 1) modulate.brightness = adj.brightness;
111
+ if (adj.saturation !== undefined && adj.saturation !== 1) modulate.saturation = adj.saturation;
112
+ if (Object.keys(modulate).length > 0) pipeline = pipeline.modulate(modulate);
113
+
114
+ if (adj.contrast !== undefined && adj.contrast !== 1) {
115
+ const a = adj.contrast;
116
+ pipeline = pipeline.linear(a, 128 * (1 - a));
117
+ }
118
+
119
+ if (adj.blur !== undefined && adj.blur > 0.3) {
120
+ pipeline = pipeline.blur(adj.blur);
121
+ }
122
+
123
+ if (adj.sharpen !== undefined && adj.sharpen > 0) {
124
+ pipeline = pipeline.sharpen({sigma: adj.sharpen});
125
+ }
126
+
127
+ if (adj.gamma !== undefined && adj.gamma !== 1) {
128
+ pipeline = pipeline.gamma(adj.gamma);
129
+ }
130
+
131
+ return pipeline;
132
+ }
133
+
134
+ /**
135
+ * Composite a watermark image onto the pipeline.
136
+ * Materialises the pipeline to an intermediate buffer so current dimensions
137
+ * are known before sizing the watermark.
138
+ *
139
+ * @param {import('sharp').Sharp} pipeline
140
+ * @param {object} wm
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)
144
+ * @param {string} wm.position e.g. 'bottom-right'
145
+ * @returns {Promise<import('sharp').Sharp>}
146
+ * @throws {Error} If watermark file cannot be read
147
+ */
148
+ async function applyWatermark(pipeline, wm) {
149
+ if (!wm?.image) return pipeline;
150
+
151
+ const intermediate = await pipeline.toBuffer();
152
+ const {width: imgWidth} = await sharp(intermediate).metadata();
153
+
154
+ const wmPath = path.join(MEDIA_DIR, wm.image);
155
+ const wmSize = Math.max(1, Math.round((wm.scale || 0.15) * imgWidth));
156
+
157
+ let wmBuffer = await sharp(wmPath)
158
+ .resize(wmSize, null, {fit: 'inside'})
159
+ .ensureAlpha()
160
+ .toBuffer();
161
+
162
+ // Apply opacity: multiply each pixel's alpha by wm.opacity using dest-in blend
163
+ const {width: wmWidth, height: wmHeight} = await sharp(wmBuffer).metadata();
164
+ const opacity = wm.opacity !== undefined ? Math.max(0, Math.min(1, wm.opacity)) : 0.5;
165
+
166
+ const maskBuffer = await sharp({
167
+ create: {width: wmWidth, height: wmHeight, channels: 4, background: {r: 0, g: 0, b: 0, alpha: opacity}},
168
+ }).png().toBuffer();
169
+
170
+ wmBuffer = await sharp(wmBuffer)
171
+ .composite([{input: maskBuffer, blend: 'dest-in'}])
172
+ .png()
173
+ .toBuffer();
174
+
175
+ const gravityMap = {
176
+ 'top-left': 'northwest', 'top-center': 'north', 'top-right': 'northeast',
177
+ 'center-left': 'west', 'center': 'centre', 'center-right': 'east',
178
+ 'bottom-left': 'southwest', 'bottom-center': 'south', 'bottom-right': 'southeast',
179
+ };
180
+ const gravity = gravityMap[wm.position || 'bottom-right'] || 'southeast';
181
+
182
+ return sharp(intermediate).composite([{input: wmBuffer, gravity}]);
183
+ }
184
+
185
+ /**
186
+ * Extend the image with a solid colour, mirror, or repeat border.
187
+ *
188
+ * @param {import('sharp').Sharp} pipeline
189
+ * @param {object} border
190
+ * @param {number} border.width Pixels per side
191
+ * @param {string} border.colour Hex colour e.g. '#ffffff'
192
+ * @param {string} border.mode 'solid' | 'mirror' | 'repeat'
193
+ * @returns {import('sharp').Sharp}
194
+ */
195
+ function applyBorder(pipeline, border) {
196
+ if (!border?.width || border.width <= 0) return pipeline;
197
+
198
+ const colour = parseHexColour(border.colour || '#000000');
199
+ const w = Math.round(border.width);
200
+ const extendWith = border.mode === 'mirror' ? 'mirror'
201
+ : border.mode === 'repeat' ? 'repeat'
202
+ : 'background';
203
+
204
+ return pipeline.extend({
205
+ top: w, right: w, bottom: w, left: w,
206
+ extendWith,
207
+ background: {r: colour.r, g: colour.g, b: colour.b, alpha: 1},
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Set the output format and quality.
213
+ *
214
+ * @param {import('sharp').Sharp} pipeline
215
+ * @param {object} format
216
+ * @param {string} format.type 'jpeg' | 'png' | 'webp'
217
+ * @param {number} [format.quality] 1–100
218
+ * @returns {import('sharp').Sharp}
219
+ */
220
+ function applyFormat(pipeline, format) {
221
+ if (!format?.type) return pipeline;
222
+ const quality = format.quality ? Math.round(format.quality) : 85;
223
+ switch (format.type) {
224
+ case 'jpeg':
225
+ return pipeline.jpeg({quality});
226
+ case 'png':
227
+ return pipeline.png();
228
+ case 'webp':
229
+ return pipeline.webp({quality});
230
+ default:
231
+ return pipeline;
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Public API
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * Apply a set of operations to an image and write the result.
241
+ *
242
+ * Pipeline order:
243
+ * rotate → flip/flop → crop → resize → preset → adjustments → watermark → border → format
244
+ *
245
+ * @param {string} filename - Source media filename
246
+ * @param {object} operations
247
+ * @param {number} [operations.rotate] - Degrees (multiple of 90)
248
+ * @param {boolean} [operations.flip] - Flip vertically
249
+ * @param {boolean} [operations.flop] - Flip horizontally
250
+ * @param {{ left, top, width, height }} [operations.crop]
251
+ * @param {{ width, height }} [operations.resize]
252
+ * @param {string} [operations.preset] - Named colour preset
253
+ * @param {object} [operations.adjustments] - Numeric slider values
254
+ * @param {object} [operations.watermark] - Watermark composite options
255
+ * @param {object} [operations.border] - Border/extend options
256
+ * @param {object} [operations.format] - Output format override
257
+ * @param {string} [operations._deleteOriginal] - Filename to delete after write
258
+ * @param {string|null} [outputFilename] - Destination filename; null = overwrite source
259
+ * @returns {Promise<{ name: string, url: string, width: number, height: number }>}
260
+ * @throws {Error} If the source file cannot be read or written
261
+ */
262
+ export async function transformImage(filename, operations = {}, outputFilename = null) {
263
+ const srcPath = path.join(MEDIA_DIR, filename);
264
+
265
+ // Read into buffer first — avoids file-lock when overwriting the source
266
+ const inputBuffer = await fs.readFile(srcPath);
267
+
268
+ let pipeline = sharp(inputBuffer);
269
+
270
+ if (operations.rotate) pipeline = pipeline.rotate(operations.rotate);
271
+ if (operations.flip) pipeline = pipeline.flip();
272
+ if (operations.flop) pipeline = pipeline.flop();
273
+
274
+ if (operations.crop) {
275
+ const {left, top, width, height} = operations.crop;
276
+ pipeline = pipeline.extract({
277
+ left: Math.round(left),
278
+ top: Math.round(top),
279
+ width: Math.round(width),
280
+ height: Math.round(height),
281
+ });
282
+ }
283
+
284
+ if (operations.resize) {
285
+ const {width, height} = operations.resize;
286
+ pipeline = pipeline.resize(width || null, height || null, {
287
+ fit: 'inside',
288
+ withoutEnlargement: true,
289
+ });
290
+ }
291
+
292
+ if (operations.preset) {
293
+ pipeline = applyPreset(pipeline, operations.preset, operations.adjustments || {});
294
+ }
295
+
296
+ if (operations.adjustments) {
297
+ pipeline = applyAdjustments(pipeline, operations.adjustments);
298
+ }
299
+
300
+ if (operations.watermark) {
301
+ pipeline = await applyWatermark(pipeline, operations.watermark);
302
+ }
303
+
304
+ if (operations.border) {
305
+ pipeline = applyBorder(pipeline, operations.border);
306
+ }
307
+
308
+ if (operations.format) {
309
+ pipeline = applyFormat(pipeline, operations.format);
310
+ }
311
+
312
+ const destFilename = outputFilename || filename;
313
+ const destPath = path.join(MEDIA_DIR, destFilename);
314
+
315
+ const outputBuffer = await pipeline.toBuffer();
316
+ await fs.writeFile(destPath, outputBuffer);
317
+
318
+ // Delete the original file when a format conversion overwrites it
319
+ if (operations._deleteOriginal && operations._deleteOriginal !== destFilename) {
320
+ const oldPath = path.join(MEDIA_DIR, operations._deleteOriginal);
321
+ await fs.unlink(oldPath).catch(() => {
322
+ });
323
+ }
324
+
325
+ const {width, height} = await sharp(outputBuffer).metadata();
326
+
327
+ const urlBase = config.content.mediaDir.replace(/^\.\/content/, '/media');
328
+ return {
329
+ name: destFilename,
330
+ url: `${urlBase}/${destFilename}`,
331
+ width,
332
+ height,
333
+ };
334
+ }