domma-cms 0.13.7 → 0.14.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/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +24 -18
- package/admin/js/lib/scribe-composer.js +4 -0
- package/admin/js/lib/simple-editor.js +49 -0
- package/admin/js/templates/block-editor.html +76 -18
- package/admin/js/templates/blocks.html +18 -8
- package/admin/js/templates/component-editor.html +141 -0
- package/admin/js/templates/components.html +18 -0
- package/admin/js/views/block-editor-enhance.js +1 -0
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +11 -4
- package/admin/js/views/component-editor.js +28 -0
- package/admin/js/views/components.js +11 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/page-editor.js +6 -6
- package/admin/js/views/pages.js +5 -2
- package/config/site.json +1 -1
- package/package.json +2 -2
- package/public/css/site.css +1 -1
- package/server/routes/api/blocks.js +128 -60
- package/server/routes/api/components.js +115 -0
- package/server/routes/api/pages.js +135 -132
- package/server/routes/api/versions.js +16 -0
- package/server/server.js +6 -0
- package/server/services/blocks.js +387 -284
- package/server/services/components.js +653 -0
- package/server/services/content.js +334 -334
- package/server/services/hooks.js +28 -0
- package/server/services/markdown.js +2836 -2629
- package/server/services/permissionRegistry.js +13 -0
- package/server/services/renderer.js +10 -2
- package/server/services/versions.js +37 -0
|
@@ -1,334 +1,334 @@
|
|
|
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
|
-
import {hooks} from './hooks.js';
|
|
11
|
-
import {createVersion, deleteAllVersions, renameVersionDir} from './versions.js';
|
|
12
|
-
|
|
13
|
-
const CONTENT_DIR = config.content.contentDir;
|
|
14
|
-
const PAGES_DIR = path.join(CONTENT_DIR, 'pages');
|
|
15
|
-
const MEDIA_DIR = config.content.mediaDir;
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Pages
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Recursively list all .md files under PAGES_DIR.
|
|
23
|
-
* Returns parsed page objects sorted by sortOrder then title.
|
|
24
|
-
*
|
|
25
|
-
* @returns {Promise<object[]>}
|
|
26
|
-
*/
|
|
27
|
-
export async function listPages() {
|
|
28
|
-
const files = await collectMdFiles(PAGES_DIR);
|
|
29
|
-
const pages = await Promise.all(files.map(filePath => readPageFile(filePath)));
|
|
30
|
-
return pages.sort((a, b) => (a.sortOrder ?? 99) - (b.sortOrder ?? 99) || a.title.localeCompare(b.title));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Get a single page by its URL path (e.g. '/about' or '/services/web-dev').
|
|
35
|
-
* The empty string / '/' resolves to index.md.
|
|
36
|
-
*
|
|
37
|
-
* @param {string} urlPath
|
|
38
|
-
* @returns {Promise<object|null>}
|
|
39
|
-
*/
|
|
40
|
-
export async function getPage(urlPath) {
|
|
41
|
-
const filePath = await resolveExistingFilePath(urlPath);
|
|
42
|
-
try {
|
|
43
|
-
return await readPageFile(filePath);
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Create a new page. Auto-creates parent directories.
|
|
51
|
-
*
|
|
52
|
-
* @param {string} urlPath
|
|
53
|
-
* @param {object} frontmatter
|
|
54
|
-
* @param {string} body
|
|
55
|
-
* @returns {Promise<object>}
|
|
56
|
-
*/
|
|
57
|
-
export async function createPage(urlPath, frontmatter, body) {
|
|
58
|
-
const filePath = urlPathToFilePath(urlPath);
|
|
59
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
60
|
-
|
|
61
|
-
const defaults = config.content.pageDefaults;
|
|
62
|
-
const now = new Date().toISOString();
|
|
63
|
-
const meta = {
|
|
64
|
-
title: frontmatter.title || 'Untitled',
|
|
65
|
-
slug: slugFromPath(urlPath),
|
|
66
|
-
description: frontmatter.description || '',
|
|
67
|
-
layout: frontmatter.layout || defaults.layout,
|
|
68
|
-
status: frontmatter.status || defaults.status,
|
|
69
|
-
sortOrder: frontmatter.sortOrder ?? defaults.sortOrder,
|
|
70
|
-
showInNav: frontmatter.showInNav ?? false,
|
|
71
|
-
sidebar: frontmatter.sidebar ?? false,
|
|
72
|
-
seo: frontmatter.seo || {},
|
|
73
|
-
dconfig: frontmatter.dconfig || null,
|
|
74
|
-
createdAt: now,
|
|
75
|
-
updatedAt: now
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
|
|
79
|
-
const page = await readPageFile(filePath);
|
|
80
|
-
hooks.emit('content:pageCreated', {page, urlPath});
|
|
81
|
-
return page;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Update an existing page.
|
|
86
|
-
*
|
|
87
|
-
* @param {string} urlPath
|
|
88
|
-
* @param {object} frontmatter
|
|
89
|
-
* @param {string} body
|
|
90
|
-
* @param {{ author?: string }} options
|
|
91
|
-
* @returns {Promise<object>}
|
|
92
|
-
*/
|
|
93
|
-
export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
|
|
94
|
-
const filePath = await resolveExistingFilePath(urlPath);
|
|
95
|
-
const existing = await readPageFile(filePath);
|
|
96
|
-
const { urlPath: _u, content: existingContent, html: _h, ...existingMeta } = existing;
|
|
97
|
-
|
|
98
|
-
const meta = {
|
|
99
|
-
...existingMeta,
|
|
100
|
-
...frontmatter,
|
|
101
|
-
updatedAt: new Date().toISOString()
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
if (existingMeta.createdAt) meta.createdAt = existingMeta.createdAt;
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
await createVersion(urlPath, {author, type: 'auto', label: null});
|
|
108
|
-
} catch {
|
|
109
|
-
// Versioning failure must not break saves
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
|
|
113
|
-
const page = await readPageFile(filePath);
|
|
114
|
-
hooks.emit('content:pageUpdated', {page, urlPath});
|
|
115
|
-
return page;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Rename (move) a page to a new URL path.
|
|
120
|
-
* Creates any required parent directories for the destination.
|
|
121
|
-
*
|
|
122
|
-
* @param {string} oldUrlPath
|
|
123
|
-
* @param {string} newUrlPath
|
|
124
|
-
* @returns {Promise<void>}
|
|
125
|
-
*/
|
|
126
|
-
export async function renamePage(oldUrlPath, newUrlPath) {
|
|
127
|
-
const oldFile = await resolveExistingFilePath(oldUrlPath);
|
|
128
|
-
const newFile = urlPathToFilePath(newUrlPath);
|
|
129
|
-
await fs.mkdir(path.dirname(newFile), { recursive: true });
|
|
130
|
-
await fs.rename(oldFile, newFile);
|
|
131
|
-
try {
|
|
132
|
-
await renameVersionDir(oldUrlPath, newUrlPath);
|
|
133
|
-
} catch {
|
|
134
|
-
// Version directory move failure is non-fatal
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Delete a page file.
|
|
140
|
-
*
|
|
141
|
-
* @param {string} urlPath
|
|
142
|
-
* @returns {Promise<void>}
|
|
143
|
-
*/
|
|
144
|
-
export async function deletePage(urlPath) {
|
|
145
|
-
const filePath = await resolveExistingFilePath(urlPath);
|
|
146
|
-
await fs.unlink(filePath);
|
|
147
|
-
hooks.emit('content:pageDeleted', {urlPath});
|
|
148
|
-
try {
|
|
149
|
-
await deleteAllVersions(urlPath);
|
|
150
|
-
} catch {
|
|
151
|
-
// Version cleanup failure is non-fatal
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// Media
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* List all files in the media directory.
|
|
161
|
-
*
|
|
162
|
-
* @returns {Promise<object[]>}
|
|
163
|
-
*/
|
|
164
|
-
export async function listMedia() {
|
|
165
|
-
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
|
166
|
-
const files = await fs.readdir(MEDIA_DIR);
|
|
167
|
-
const stats = await Promise.all(
|
|
168
|
-
files.map(async (file) => {
|
|
169
|
-
const filePath = path.join(MEDIA_DIR, file);
|
|
170
|
-
const stat = await fs.stat(filePath);
|
|
171
|
-
return {
|
|
172
|
-
name: file,
|
|
173
|
-
url: `/media/${file}`,
|
|
174
|
-
size: stat.size,
|
|
175
|
-
createdAt: stat.birthtime.toISOString()
|
|
176
|
-
};
|
|
177
|
-
})
|
|
178
|
-
);
|
|
179
|
-
return stats.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Save an uploaded file to the media directory.
|
|
184
|
-
*
|
|
185
|
-
* @param {string} filename
|
|
186
|
-
* @param {Buffer} buffer
|
|
187
|
-
* @returns {Promise<object>}
|
|
188
|
-
*/
|
|
189
|
-
export async function saveMedia(filename, buffer) {
|
|
190
|
-
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
|
191
|
-
const filePath = path.join(MEDIA_DIR, filename);
|
|
192
|
-
await fs.writeFile(filePath, buffer);
|
|
193
|
-
hooks.emit('content:mediaUploaded', {filename});
|
|
194
|
-
return { name: filename, url: `/media/${filename}` };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Delete a media file.
|
|
199
|
-
*
|
|
200
|
-
* @param {string} filename
|
|
201
|
-
* @returns {Promise<void>}
|
|
202
|
-
*/
|
|
203
|
-
export async function deleteMedia(filename) {
|
|
204
|
-
const filePath = path.join(MEDIA_DIR, filename);
|
|
205
|
-
await fs.unlink(filePath);
|
|
206
|
-
hooks.emit('content:mediaDeleted', {filename});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Rename a media file.
|
|
211
|
-
*
|
|
212
|
-
* @param {string} oldName - Current filename
|
|
213
|
-
* @param {string} newName - Desired filename
|
|
214
|
-
* @returns {Promise<{ name: string, url: string }>}
|
|
215
|
-
* @throws {Error} If the destination already exists
|
|
216
|
-
*/
|
|
217
|
-
export async function renameMedia(oldName, newName) {
|
|
218
|
-
const srcPath = path.join(MEDIA_DIR, oldName);
|
|
219
|
-
const destPath = path.join(MEDIA_DIR, newName);
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
await fs.access(destPath);
|
|
223
|
-
throw new Error(`A file named "${newName}" already exists.`);
|
|
224
|
-
} catch (err) {
|
|
225
|
-
if (err.code !== 'ENOENT') throw err;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
await fs.rename(srcPath, destPath);
|
|
229
|
-
return {name: newName, url: `/media/${newName}`};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// ---------------------------------------------------------------------------
|
|
233
|
-
// Helpers
|
|
234
|
-
// ---------------------------------------------------------------------------
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Resolve the actual file path for an existing page.
|
|
238
|
-
* Tries the direct file first (e.g. about.md), then the directory index (e.g. blog/index.md).
|
|
239
|
-
*
|
|
240
|
-
* @param {string} urlPath
|
|
241
|
-
* @returns {Promise<string>}
|
|
242
|
-
*/
|
|
243
|
-
export async function resolveExistingFilePath(urlPath) {
|
|
244
|
-
const directFile = urlPathToFilePath(urlPath);
|
|
245
|
-
try {
|
|
246
|
-
await fs.access(directFile);
|
|
247
|
-
return directFile;
|
|
248
|
-
} catch {
|
|
249
|
-
const normalised = urlPath === '/' || !urlPath ? 'index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
|
|
250
|
-
const fallbackPath = path.join(PAGES_DIR, normalised, 'index.md');
|
|
251
|
-
if (!fallbackPath.startsWith(PAGES_DIR + path.sep)) {
|
|
252
|
-
throw new Error('Path traversal detected in URL path');
|
|
253
|
-
}
|
|
254
|
-
return fallbackPath;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Recursively collect all .md file paths under a directory.
|
|
260
|
-
*
|
|
261
|
-
* @param {string} dir
|
|
262
|
-
* @returns {Promise<string[]>}
|
|
263
|
-
*/
|
|
264
|
-
async function collectMdFiles(dir) {
|
|
265
|
-
let results = [];
|
|
266
|
-
let entries;
|
|
267
|
-
try {
|
|
268
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
269
|
-
} catch {
|
|
270
|
-
return results;
|
|
271
|
-
}
|
|
272
|
-
for (const entry of entries) {
|
|
273
|
-
const full = path.join(dir, entry.name);
|
|
274
|
-
if (entry.isDirectory()) {
|
|
275
|
-
results = results.concat(await collectMdFiles(full));
|
|
276
|
-
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
277
|
-
results.push(full);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
return results;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Read and parse a single .md file, injecting the derived urlPath.
|
|
285
|
-
*
|
|
286
|
-
* @param {string} filePath
|
|
287
|
-
* @returns {Promise<object>}
|
|
288
|
-
*/
|
|
289
|
-
async function readPageFile(filePath) {
|
|
290
|
-
const raw = await fs.readFile(filePath, 'utf8');
|
|
291
|
-
const { data, content, html } = await parseMarkdown(raw);
|
|
292
|
-
const urlPath = filePathToUrlPath(filePath);
|
|
293
|
-
return { ...data, urlPath, content, html };
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Derive the URL path from a file path.
|
|
298
|
-
* e.g. content/pages/services/web-dev.md → /services/web-dev
|
|
299
|
-
* content/pages/index.md → /
|
|
300
|
-
* content/pages/services/index.md → /services
|
|
301
|
-
*/
|
|
302
|
-
function filePathToUrlPath(filePath) {
|
|
303
|
-
const relative = path.relative(PAGES_DIR, filePath);
|
|
304
|
-
const withoutExt = relative.replace(/\.md$/, '');
|
|
305
|
-
if (withoutExt === 'index') return '/';
|
|
306
|
-
const parts = withoutExt.split(path.sep);
|
|
307
|
-
if (parts[parts.length - 1] === 'index') parts.pop();
|
|
308
|
-
return '/' + parts.join('/');
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Derive the file path from a URL path.
|
|
313
|
-
* e.g. / → content/pages/index.md
|
|
314
|
-
* /about → content/pages/about.md
|
|
315
|
-
* /services → content/pages/services/index.md (checked second)
|
|
316
|
-
*/
|
|
317
|
-
function urlPathToFilePath(urlPath) {
|
|
318
|
-
const normalised = urlPath === '/' || !urlPath ? 'index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
|
|
319
|
-
const result = path.join(PAGES_DIR, normalised + '.md');
|
|
320
|
-
const resolved = path.resolve(result);
|
|
321
|
-
if (!resolved.startsWith(path.resolve(PAGES_DIR))) {
|
|
322
|
-
throw new Error('Invalid path: outside content directory');
|
|
323
|
-
}
|
|
324
|
-
return result;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Extract a slug from a URL path.
|
|
329
|
-
*/
|
|
330
|
-
function slugFromPath(urlPath) {
|
|
331
|
-
if (urlPath === '/' || !urlPath) return 'index';
|
|
332
|
-
const parts = urlPath.split('/').filter(Boolean);
|
|
333
|
-
return parts[parts.length - 1] || 'index';
|
|
334
|
-
}
|
|
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
|
+
import {hooks} from './hooks.js';
|
|
11
|
+
import {createVersion, deleteAllVersions, renameVersionDir} from './versions.js';
|
|
12
|
+
|
|
13
|
+
const CONTENT_DIR = config.content.contentDir;
|
|
14
|
+
const PAGES_DIR = path.join(CONTENT_DIR, 'pages');
|
|
15
|
+
const MEDIA_DIR = config.content.mediaDir;
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Pages
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recursively list all .md files under PAGES_DIR.
|
|
23
|
+
* Returns parsed page objects sorted by sortOrder then title.
|
|
24
|
+
*
|
|
25
|
+
* @returns {Promise<object[]>}
|
|
26
|
+
*/
|
|
27
|
+
export async function listPages() {
|
|
28
|
+
const files = await collectMdFiles(PAGES_DIR);
|
|
29
|
+
const pages = await Promise.all(files.map(filePath => readPageFile(filePath)));
|
|
30
|
+
return pages.sort((a, b) => (a.sortOrder ?? 99) - (b.sortOrder ?? 99) || a.title.localeCompare(b.title));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get a single page by its URL path (e.g. '/about' or '/services/web-dev').
|
|
35
|
+
* The empty string / '/' resolves to index.md.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} urlPath
|
|
38
|
+
* @returns {Promise<object|null>}
|
|
39
|
+
*/
|
|
40
|
+
export async function getPage(urlPath) {
|
|
41
|
+
const filePath = await resolveExistingFilePath(urlPath);
|
|
42
|
+
try {
|
|
43
|
+
return await readPageFile(filePath);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a new page. Auto-creates parent directories.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} urlPath
|
|
53
|
+
* @param {object} frontmatter
|
|
54
|
+
* @param {string} body
|
|
55
|
+
* @returns {Promise<object>}
|
|
56
|
+
*/
|
|
57
|
+
export async function createPage(urlPath, frontmatter, body) {
|
|
58
|
+
const filePath = urlPathToFilePath(urlPath);
|
|
59
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
60
|
+
|
|
61
|
+
const defaults = config.content.pageDefaults;
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
const meta = {
|
|
64
|
+
title: frontmatter.title || 'Untitled',
|
|
65
|
+
slug: slugFromPath(urlPath),
|
|
66
|
+
description: frontmatter.description || '',
|
|
67
|
+
layout: frontmatter.layout || defaults.layout,
|
|
68
|
+
status: frontmatter.status || defaults.status,
|
|
69
|
+
sortOrder: frontmatter.sortOrder ?? defaults.sortOrder,
|
|
70
|
+
showInNav: frontmatter.showInNav ?? false,
|
|
71
|
+
sidebar: frontmatter.sidebar ?? false,
|
|
72
|
+
seo: frontmatter.seo || {},
|
|
73
|
+
dconfig: frontmatter.dconfig || null,
|
|
74
|
+
createdAt: now,
|
|
75
|
+
updatedAt: now
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
|
|
79
|
+
const page = await readPageFile(filePath);
|
|
80
|
+
hooks.emit('content:pageCreated', {page, urlPath});
|
|
81
|
+
return page;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Update an existing page.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} urlPath
|
|
88
|
+
* @param {object} frontmatter
|
|
89
|
+
* @param {string} body
|
|
90
|
+
* @param {{ author?: string }} options
|
|
91
|
+
* @returns {Promise<object>}
|
|
92
|
+
*/
|
|
93
|
+
export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
|
|
94
|
+
const filePath = await resolveExistingFilePath(urlPath);
|
|
95
|
+
const existing = await readPageFile(filePath);
|
|
96
|
+
const { urlPath: _u, content: existingContent, html: _h, ...existingMeta } = existing;
|
|
97
|
+
|
|
98
|
+
const meta = {
|
|
99
|
+
...existingMeta,
|
|
100
|
+
...frontmatter,
|
|
101
|
+
updatedAt: new Date().toISOString()
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (existingMeta.createdAt) meta.createdAt = existingMeta.createdAt;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await createVersion(urlPath, {author, type: 'auto', label: null});
|
|
108
|
+
} catch {
|
|
109
|
+
// Versioning failure must not break saves
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
|
|
113
|
+
const page = await readPageFile(filePath);
|
|
114
|
+
hooks.emit('content:pageUpdated', {page, urlPath});
|
|
115
|
+
return page;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Rename (move) a page to a new URL path.
|
|
120
|
+
* Creates any required parent directories for the destination.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} oldUrlPath
|
|
123
|
+
* @param {string} newUrlPath
|
|
124
|
+
* @returns {Promise<void>}
|
|
125
|
+
*/
|
|
126
|
+
export async function renamePage(oldUrlPath, newUrlPath) {
|
|
127
|
+
const oldFile = await resolveExistingFilePath(oldUrlPath);
|
|
128
|
+
const newFile = urlPathToFilePath(newUrlPath);
|
|
129
|
+
await fs.mkdir(path.dirname(newFile), { recursive: true });
|
|
130
|
+
await fs.rename(oldFile, newFile);
|
|
131
|
+
try {
|
|
132
|
+
await renameVersionDir(oldUrlPath, newUrlPath);
|
|
133
|
+
} catch {
|
|
134
|
+
// Version directory move failure is non-fatal
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Delete a page file.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} urlPath
|
|
142
|
+
* @returns {Promise<void>}
|
|
143
|
+
*/
|
|
144
|
+
export async function deletePage(urlPath) {
|
|
145
|
+
const filePath = await resolveExistingFilePath(urlPath);
|
|
146
|
+
await fs.unlink(filePath);
|
|
147
|
+
hooks.emit('content:pageDeleted', {urlPath});
|
|
148
|
+
try {
|
|
149
|
+
await deleteAllVersions(urlPath);
|
|
150
|
+
} catch {
|
|
151
|
+
// Version cleanup failure is non-fatal
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Media
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* List all files in the media directory.
|
|
161
|
+
*
|
|
162
|
+
* @returns {Promise<object[]>}
|
|
163
|
+
*/
|
|
164
|
+
export async function listMedia() {
|
|
165
|
+
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
|
166
|
+
const files = await fs.readdir(MEDIA_DIR);
|
|
167
|
+
const stats = await Promise.all(
|
|
168
|
+
files.map(async (file) => {
|
|
169
|
+
const filePath = path.join(MEDIA_DIR, file);
|
|
170
|
+
const stat = await fs.stat(filePath);
|
|
171
|
+
return {
|
|
172
|
+
name: file,
|
|
173
|
+
url: `/media/${file}`,
|
|
174
|
+
size: stat.size,
|
|
175
|
+
createdAt: stat.birthtime.toISOString()
|
|
176
|
+
};
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
return stats.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Save an uploaded file to the media directory.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} filename
|
|
186
|
+
* @param {Buffer} buffer
|
|
187
|
+
* @returns {Promise<object>}
|
|
188
|
+
*/
|
|
189
|
+
export async function saveMedia(filename, buffer) {
|
|
190
|
+
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
|
191
|
+
const filePath = path.join(MEDIA_DIR, filename);
|
|
192
|
+
await fs.writeFile(filePath, buffer);
|
|
193
|
+
hooks.emit('content:mediaUploaded', {filename});
|
|
194
|
+
return { name: filename, url: `/media/${filename}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Delete a media file.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} filename
|
|
201
|
+
* @returns {Promise<void>}
|
|
202
|
+
*/
|
|
203
|
+
export async function deleteMedia(filename) {
|
|
204
|
+
const filePath = path.join(MEDIA_DIR, filename);
|
|
205
|
+
await fs.unlink(filePath);
|
|
206
|
+
hooks.emit('content:mediaDeleted', {filename});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Rename a media file.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} oldName - Current filename
|
|
213
|
+
* @param {string} newName - Desired filename
|
|
214
|
+
* @returns {Promise<{ name: string, url: string }>}
|
|
215
|
+
* @throws {Error} If the destination already exists
|
|
216
|
+
*/
|
|
217
|
+
export async function renameMedia(oldName, newName) {
|
|
218
|
+
const srcPath = path.join(MEDIA_DIR, oldName);
|
|
219
|
+
const destPath = path.join(MEDIA_DIR, newName);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await fs.access(destPath);
|
|
223
|
+
throw new Error(`A file named "${newName}" already exists.`);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err.code !== 'ENOENT') throw err;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await fs.rename(srcPath, destPath);
|
|
229
|
+
return {name: newName, url: `/media/${newName}`};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Helpers
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Resolve the actual file path for an existing page.
|
|
238
|
+
* Tries the direct file first (e.g. about.md), then the directory index (e.g. blog/index.md).
|
|
239
|
+
*
|
|
240
|
+
* @param {string} urlPath
|
|
241
|
+
* @returns {Promise<string>}
|
|
242
|
+
*/
|
|
243
|
+
export async function resolveExistingFilePath(urlPath) {
|
|
244
|
+
const directFile = urlPathToFilePath(urlPath);
|
|
245
|
+
try {
|
|
246
|
+
await fs.access(directFile);
|
|
247
|
+
return directFile;
|
|
248
|
+
} catch {
|
|
249
|
+
const normalised = urlPath === '/' || !urlPath ? 'index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
|
|
250
|
+
const fallbackPath = path.join(PAGES_DIR, normalised, 'index.md');
|
|
251
|
+
if (!fallbackPath.startsWith(PAGES_DIR + path.sep)) {
|
|
252
|
+
throw new Error('Path traversal detected in URL path');
|
|
253
|
+
}
|
|
254
|
+
return fallbackPath;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Recursively collect all .md file paths under a directory.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} dir
|
|
262
|
+
* @returns {Promise<string[]>}
|
|
263
|
+
*/
|
|
264
|
+
async function collectMdFiles(dir) {
|
|
265
|
+
let results = [];
|
|
266
|
+
let entries;
|
|
267
|
+
try {
|
|
268
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
269
|
+
} catch {
|
|
270
|
+
return results;
|
|
271
|
+
}
|
|
272
|
+
for (const entry of entries) {
|
|
273
|
+
const full = path.join(dir, entry.name);
|
|
274
|
+
if (entry.isDirectory()) {
|
|
275
|
+
results = results.concat(await collectMdFiles(full));
|
|
276
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
277
|
+
results.push(full);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return results;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Read and parse a single .md file, injecting the derived urlPath.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} filePath
|
|
287
|
+
* @returns {Promise<object>}
|
|
288
|
+
*/
|
|
289
|
+
async function readPageFile(filePath) {
|
|
290
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
291
|
+
const { data, content, html, usedComponents } = await parseMarkdown(raw);
|
|
292
|
+
const urlPath = filePathToUrlPath(filePath);
|
|
293
|
+
return { ...data, urlPath, content, html, usedComponents };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Derive the URL path from a file path.
|
|
298
|
+
* e.g. content/pages/services/web-dev.md → /services/web-dev
|
|
299
|
+
* content/pages/index.md → /
|
|
300
|
+
* content/pages/services/index.md → /services
|
|
301
|
+
*/
|
|
302
|
+
function filePathToUrlPath(filePath) {
|
|
303
|
+
const relative = path.relative(PAGES_DIR, filePath);
|
|
304
|
+
const withoutExt = relative.replace(/\.md$/, '');
|
|
305
|
+
if (withoutExt === 'index') return '/';
|
|
306
|
+
const parts = withoutExt.split(path.sep);
|
|
307
|
+
if (parts[parts.length - 1] === 'index') parts.pop();
|
|
308
|
+
return '/' + parts.join('/');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Derive the file path from a URL path.
|
|
313
|
+
* e.g. / → content/pages/index.md
|
|
314
|
+
* /about → content/pages/about.md
|
|
315
|
+
* /services → content/pages/services/index.md (checked second)
|
|
316
|
+
*/
|
|
317
|
+
function urlPathToFilePath(urlPath) {
|
|
318
|
+
const normalised = urlPath === '/' || !urlPath ? 'index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
|
|
319
|
+
const result = path.join(PAGES_DIR, normalised + '.md');
|
|
320
|
+
const resolved = path.resolve(result);
|
|
321
|
+
if (!resolved.startsWith(path.resolve(PAGES_DIR))) {
|
|
322
|
+
throw new Error('Invalid path: outside content directory');
|
|
323
|
+
}
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Extract a slug from a URL path.
|
|
329
|
+
*/
|
|
330
|
+
function slugFromPath(urlPath) {
|
|
331
|
+
if (urlPath === '/' || !urlPath) return 'index';
|
|
332
|
+
const parts = urlPath.split('/').filter(Boolean);
|
|
333
|
+
return parts[parts.length - 1] || 'index';
|
|
334
|
+
}
|