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.
- package/LICENSE +21 -0
- package/README.md +469 -0
- package/admin/css/admin.css +1123 -0
- package/admin/index.html +72 -0
- package/admin/js/api.js +210 -0
- package/admin/js/app.js +270 -0
- package/admin/js/config/sidebar-config.js +107 -0
- package/admin/js/lib/card.js +63 -0
- package/admin/js/lib/image-editor.js +869 -0
- package/admin/js/lib/markdown-toolbar.js +421 -0
- package/admin/js/templates/dashboard.html +50 -0
- package/admin/js/templates/documentation.html +237 -0
- package/admin/js/templates/layouts.html +11 -0
- package/admin/js/templates/login.html +58 -0
- package/admin/js/templates/media.html +16 -0
- package/admin/js/templates/navigation.html +50 -0
- package/admin/js/templates/page-editor.html +126 -0
- package/admin/js/templates/pages.html +18 -0
- package/admin/js/templates/plugins.html +12 -0
- package/admin/js/templates/settings.html +190 -0
- package/admin/js/templates/tutorials.html +233 -0
- package/admin/js/templates/user-editor.html +12 -0
- package/admin/js/templates/users.html +10 -0
- package/admin/js/views/dashboard.js +48 -0
- package/admin/js/views/documentation.js +12 -0
- package/admin/js/views/index.js +33 -0
- package/admin/js/views/layouts.js +49 -0
- package/admin/js/views/login.js +254 -0
- package/admin/js/views/media.js +240 -0
- package/admin/js/views/navigation.js +152 -0
- package/admin/js/views/page-editor.js +479 -0
- package/admin/js/views/pages.js +64 -0
- package/admin/js/views/plugins.js +100 -0
- package/admin/js/views/settings.js +64 -0
- package/admin/js/views/tutorials.js +12 -0
- package/admin/js/views/user-editor.js +88 -0
- package/admin/js/views/users.js +73 -0
- package/bin/cli.js +334 -0
- package/config/auth.json +20 -0
- package/config/content.json +10 -0
- package/config/navigation.json +63 -0
- package/config/plugins.json +47 -0
- package/config/presets.json +34 -0
- package/config/server.json +6 -0
- package/config/site.json +33 -0
- package/package.json +67 -0
- package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
- package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
- package/plugins/back-to-top/config.js +10 -0
- package/plugins/back-to-top/plugin.js +24 -0
- package/plugins/back-to-top/plugin.json +36 -0
- package/plugins/back-to-top/public/inject-body.html +105 -0
- package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
- package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
- package/plugins/cookie-consent/config.js +30 -0
- package/plugins/cookie-consent/plugin.js +24 -0
- package/plugins/cookie-consent/plugin.json +36 -0
- package/plugins/cookie-consent/public/inject-body.html +69 -0
- package/plugins/custom-css/admin/templates/custom-css.html +17 -0
- package/plugins/custom-css/admin/views/custom-css.js +35 -0
- package/plugins/custom-css/config.js +1 -0
- package/plugins/custom-css/data/custom.css +0 -0
- package/plugins/custom-css/plugin.js +63 -0
- package/plugins/custom-css/plugin.json +32 -0
- package/plugins/custom-css/public/inject-head.html +1 -0
- package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
- package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
- package/plugins/domma-effects/config.js +9 -0
- package/plugins/domma-effects/plugin.js +22 -0
- package/plugins/domma-effects/plugin.json +36 -0
- package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
- package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
- package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
- package/plugins/domma-effects/public/celebrations/index.js +535 -0
- package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
- package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
- package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
- package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
- package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
- package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
- package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
- package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
- package/plugins/domma-effects/public/inject-body.html +268 -0
- package/plugins/example-analytics/admin/templates/analytics.html +10 -0
- package/plugins/example-analytics/admin/views/analytics.js +51 -0
- package/plugins/example-analytics/config.js +6 -0
- package/plugins/example-analytics/plugin.js +58 -0
- package/plugins/example-analytics/plugin.json +27 -0
- package/plugins/example-analytics/public/inject-body.html +13 -0
- package/plugins/example-analytics/public/inject-head.html +1 -0
- package/plugins/example-analytics/stats.json +1 -0
- package/plugins/form-builder/admin/templates/form-editor.html +158 -0
- package/plugins/form-builder/admin/templates/form-settings.html +29 -0
- package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
- package/plugins/form-builder/admin/templates/forms-list.html +17 -0
- package/plugins/form-builder/admin/views/form-editor.js +817 -0
- package/plugins/form-builder/admin/views/form-settings.js +38 -0
- package/plugins/form-builder/admin/views/form-submissions.js +295 -0
- package/plugins/form-builder/admin/views/forms-list.js +164 -0
- package/plugins/form-builder/config.js +9 -0
- package/plugins/form-builder/data/forms/contact-details.json +63 -0
- package/plugins/form-builder/data/forms/contact.json +52 -0
- package/plugins/form-builder/data/submissions/contact-details.json +1 -0
- package/plugins/form-builder/data/submissions/contact.json +14 -0
- package/plugins/form-builder/email.js +103 -0
- package/plugins/form-builder/plugin.js +454 -0
- package/plugins/form-builder/plugin.json +56 -0
- package/plugins/form-builder/public/inject-body.html +270 -0
- package/plugins/form-builder/public/inject-head.html +42 -0
- package/public/css/site.css +189 -0
- package/public/js/site.js +109 -0
- package/scripts/copy-domma.js +48 -0
- package/scripts/fresh.js +41 -0
- package/scripts/reset.js +124 -0
- package/scripts/seed.js +666 -0
- package/scripts/setup.js +263 -0
- package/server/config.js +56 -0
- package/server/middleware/auth.js +97 -0
- package/server/routes/api/auth.js +116 -0
- package/server/routes/api/layouts.js +25 -0
- package/server/routes/api/media.js +93 -0
- package/server/routes/api/navigation.js +37 -0
- package/server/routes/api/pages.js +118 -0
- package/server/routes/api/plugins.js +46 -0
- package/server/routes/api/settings.js +25 -0
- package/server/routes/api/users.js +110 -0
- package/server/routes/public.js +108 -0
- package/server/server.js +169 -0
- package/server/services/content.js +298 -0
- package/server/services/images.js +334 -0
- package/server/services/markdown.js +297 -0
- package/server/services/plugins.js +246 -0
- package/server/services/renderer.js +80 -0
- package/server/services/users.js +212 -0
- 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
|
+
}
|