domma-cms 0.13.5 → 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.
Files changed (40) hide show
  1. package/admin/css/admin.css +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/config/sidebar-config.js +1 -1
  5. package/admin/js/lib/markdown-toolbar.js +24 -18
  6. package/admin/js/lib/scribe-composer.js +4 -0
  7. package/admin/js/lib/simple-editor.js +49 -0
  8. package/admin/js/templates/block-editor.html +76 -18
  9. package/admin/js/templates/blocks.html +18 -8
  10. package/admin/js/templates/component-editor.html +141 -0
  11. package/admin/js/templates/components.html +18 -0
  12. package/admin/js/views/block-editor-enhance.js +1 -0
  13. package/admin/js/views/block-editor.js +8 -8
  14. package/admin/js/views/blocks.js +11 -4
  15. package/admin/js/views/component-editor.js +28 -0
  16. package/admin/js/views/components.js +11 -0
  17. package/admin/js/views/index.js +1 -1
  18. package/admin/js/views/layouts.js +1 -1
  19. package/admin/js/views/page-editor.js +6 -6
  20. package/admin/js/views/pages.js +5 -2
  21. package/config/navigation.json +5 -0
  22. package/config/plugins.json +5 -5
  23. package/config/presets.json +92 -40
  24. package/config/site.json +75 -8
  25. package/package.json +2 -2
  26. package/public/css/site.css +1 -1
  27. package/server/routes/api/blocks.js +128 -60
  28. package/server/routes/api/components.js +115 -0
  29. package/server/routes/api/layouts.js +24 -0
  30. package/server/routes/api/pages.js +135 -132
  31. package/server/routes/api/versions.js +16 -0
  32. package/server/server.js +6 -0
  33. package/server/services/blocks.js +387 -284
  34. package/server/services/components.js +653 -0
  35. package/server/services/content.js +334 -334
  36. package/server/services/hooks.js +28 -0
  37. package/server/services/markdown.js +2836 -2629
  38. package/server/services/permissionRegistry.js +13 -0
  39. package/server/services/renderer.js +13 -3
  40. 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
+ }