domma-cms 0.1.0 → 0.2.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 +78 -1
- package/admin/js/api.js +32 -0
- package/admin/js/app.js +24 -7
- package/admin/js/config/sidebar-config.js +8 -0
- package/admin/js/templates/collection-editor.html +80 -0
- package/admin/js/templates/collection-entries.html +36 -0
- package/admin/js/templates/collections.html +12 -0
- package/admin/js/templates/documentation.html +136 -0
- package/admin/js/templates/navigation.html +26 -4
- package/admin/js/templates/page-editor.html +91 -85
- package/admin/js/templates/settings.html +433 -172
- package/admin/js/views/collection-editor.js +487 -0
- package/admin/js/views/collection-entries.js +484 -0
- package/admin/js/views/collections.js +153 -0
- package/admin/js/views/dashboard.js +14 -6
- package/admin/js/views/index.js +9 -3
- package/admin/js/views/login.js +3 -2
- package/admin/js/views/navigation.js +77 -11
- package/admin/js/views/page-editor.js +207 -25
- package/admin/js/views/pages.js +14 -6
- package/admin/js/views/settings.js +137 -2
- package/admin/js/views/users.js +10 -7
- package/bin/cli.js +37 -10
- package/config/auth.json +2 -1
- package/config/content.json +1 -0
- package/config/navigation.json +14 -4
- package/config/plugins.json +0 -18
- package/config/presets.json +4 -8
- package/config/site.json +44 -3
- package/package.json +6 -2
- package/plugins/domma-effects/admin/templates/domma-effects.html +92 -3
- package/plugins/domma-effects/plugin.js +125 -0
- package/plugins/domma-effects/public/inject-body.html +19 -0
- package/plugins/example-analytics/admin/views/analytics.js +2 -2
- package/plugins/example-analytics/plugin.json +8 -0
- package/plugins/example-analytics/stats.json +15 -1
- package/plugins/form-builder/admin/templates/form-editor.html +19 -6
- package/plugins/form-builder/admin/views/form-editor.js +634 -9
- package/plugins/form-builder/admin/views/form-submissions.js +4 -4
- package/plugins/form-builder/admin/views/forms-list.js +5 -5
- package/plugins/form-builder/data/forms/consent.json +104 -0
- package/plugins/form-builder/data/forms/contacts.json +66 -0
- package/plugins/form-builder/data/submissions/consent.json +13 -0
- package/plugins/form-builder/data/submissions/contacts.json +26 -0
- package/plugins/form-builder/plugin.js +62 -11
- package/plugins/form-builder/plugin.json +12 -16
- package/plugins/form-builder/public/form-logic-engine.js +568 -0
- package/plugins/form-builder/public/inject-body.html +88 -6
- package/plugins/form-builder/public/inject-head.html +16 -0
- package/plugins/form-builder/public/package.json +1 -0
- package/public/css/site.css +113 -0
- package/public/js/btt.js +90 -0
- package/public/js/cookie-consent.js +61 -0
- package/public/js/site.js +129 -34
- package/scripts/build.js +129 -0
- package/scripts/seed.js +517 -7
- package/server/routes/api/collections.js +301 -0
- package/server/routes/api/settings.js +66 -2
- package/server/server.js +19 -15
- package/server/services/collections.js +430 -0
- package/server/services/content.js +11 -2
- package/server/services/hooks.js +109 -0
- package/server/services/markdown.js +500 -149
- package/server/services/plugins.js +6 -1
- package/server/services/renderer.js +73 -7
- package/server/templates/page.html +38 -3
- package/plugins/back-to-top/admin/templates/back-to-top-settings.html +0 -55
- package/plugins/back-to-top/admin/views/back-to-top-settings.js +0 -44
- package/plugins/back-to-top/config.js +0 -10
- package/plugins/back-to-top/plugin.js +0 -24
- package/plugins/back-to-top/plugin.json +0 -36
- package/plugins/back-to-top/public/inject-body.html +0 -105
- package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +0 -113
- package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +0 -73
- package/plugins/cookie-consent/config.js +0 -30
- package/plugins/cookie-consent/plugin.js +0 -24
- package/plugins/cookie-consent/plugin.json +0 -36
- package/plugins/cookie-consent/public/inject-body.html +0 -69
- package/plugins/custom-css/admin/templates/custom-css.html +0 -17
- package/plugins/custom-css/admin/views/custom-css.js +0 -35
- package/plugins/custom-css/config.js +0 -1
- package/plugins/custom-css/data/custom.css +0 -0
- package/plugins/custom-css/plugin.js +0 -63
- package/plugins/custom-css/plugin.json +0 -32
- package/plugins/custom-css/public/inject-head.html +0 -1
- package/plugins/form-builder/data/forms/contact.json +0 -52
- package/plugins/form-builder/data/submissions/contact.json +0 -14
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collections Service
|
|
3
|
+
* Schema-first data store — one directory per collection under content/collections/{slug}/.
|
|
4
|
+
* Each collection has a schema.json (field definitions + API access config) and a data.json
|
|
5
|
+
* (array of entries with id, data, and meta).
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
10
|
+
import { config } from '../config.js';
|
|
11
|
+
|
|
12
|
+
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Internal helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
async function ensureDir() {
|
|
19
|
+
await fs.mkdir(COLLECTIONS_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function collectionDir(slug) {
|
|
23
|
+
return path.join(COLLECTIONS_DIR, slug);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function schemaPath(slug) {
|
|
27
|
+
return path.join(collectionDir(slug), 'schema.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function dataPath(slug) {
|
|
31
|
+
return path.join(collectionDir(slug), 'data.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function slugify(str) {
|
|
35
|
+
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readSchema(slug) {
|
|
39
|
+
const raw = await fs.readFile(schemaPath(slug), 'utf8');
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function writeSchema(schema) {
|
|
44
|
+
await fs.writeFile(schemaPath(schema.slug), JSON.stringify(schema, null, 2) + '\n', 'utf8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readData(slug) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = await fs.readFile(dataPath(slug), 'utf8');
|
|
50
|
+
return JSON.parse(raw);
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function writeData(slug, entries) {
|
|
57
|
+
await fs.writeFile(dataPath(slug), JSON.stringify(entries, null, 2) + '\n', 'utf8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Default API access configuration for a new collection.
|
|
62
|
+
*
|
|
63
|
+
* @returns {object}
|
|
64
|
+
*/
|
|
65
|
+
function defaultApiAccess() {
|
|
66
|
+
return {
|
|
67
|
+
create: { enabled: false, access: 'admin' },
|
|
68
|
+
read: { enabled: true, access: 'public' },
|
|
69
|
+
update: { enabled: false, access: 'admin' },
|
|
70
|
+
delete: { enabled: false, access: 'admin' }
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Schema (collection) operations
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* List all collections — reads all schema.json files and appends entryCount.
|
|
80
|
+
*
|
|
81
|
+
* @returns {Promise<object[]>}
|
|
82
|
+
*/
|
|
83
|
+
export async function listCollections() {
|
|
84
|
+
await ensureDir();
|
|
85
|
+
let slugs;
|
|
86
|
+
try {
|
|
87
|
+
const entries = await fs.readdir(COLLECTIONS_DIR, { withFileTypes: true });
|
|
88
|
+
slugs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const results = await Promise.allSettled(slugs.map(async (slug) => {
|
|
94
|
+
const schema = await readSchema(slug);
|
|
95
|
+
const data = await readData(slug);
|
|
96
|
+
return { ...schema, entryCount: data.length };
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
return results
|
|
100
|
+
.filter(r => r.status === 'fulfilled')
|
|
101
|
+
.map(r => r.value)
|
|
102
|
+
.sort((a, b) => a.title.localeCompare(b.title));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get a single collection schema.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} slug
|
|
109
|
+
* @returns {Promise<object|null>}
|
|
110
|
+
*/
|
|
111
|
+
export async function getCollection(slug) {
|
|
112
|
+
try {
|
|
113
|
+
return await readSchema(slug);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a new collection.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} opts
|
|
123
|
+
* @param {string} opts.title
|
|
124
|
+
* @param {string} [opts.slug] - Auto-generated from title if omitted
|
|
125
|
+
* @param {string} [opts.description]
|
|
126
|
+
* @param {object[]} [opts.fields]
|
|
127
|
+
* @param {object} [opts.api]
|
|
128
|
+
* @returns {Promise<object>} Created schema
|
|
129
|
+
* @throws {Error} If a collection with that slug already exists
|
|
130
|
+
*/
|
|
131
|
+
export async function createCollection({ title, slug, description = '', fields = [], api = {} }) {
|
|
132
|
+
await ensureDir();
|
|
133
|
+
const finalSlug = slug ? slugify(slug) : slugify(title);
|
|
134
|
+
if (!finalSlug) throw new Error('Could not derive a slug from the title');
|
|
135
|
+
|
|
136
|
+
const dir = collectionDir(finalSlug);
|
|
137
|
+
try {
|
|
138
|
+
await fs.mkdir(dir, { recursive: false });
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err.code === 'EEXIST') throw new Error(`A collection with slug "${finalSlug}" already exists`);
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const now = new Date().toISOString();
|
|
145
|
+
const schema = {
|
|
146
|
+
slug: finalSlug,
|
|
147
|
+
title: title.trim(),
|
|
148
|
+
description: description.trim(),
|
|
149
|
+
fields,
|
|
150
|
+
api: { ...defaultApiAccess(), ...api },
|
|
151
|
+
createdAt: now,
|
|
152
|
+
updatedAt: now
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await writeSchema(schema);
|
|
156
|
+
await writeData(finalSlug, []);
|
|
157
|
+
return schema;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Update a collection schema.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} slug
|
|
164
|
+
* @param {object} updates - Partial schema fields to merge
|
|
165
|
+
* @returns {Promise<object>} Updated schema
|
|
166
|
+
* @throws {Error} If collection not found
|
|
167
|
+
*/
|
|
168
|
+
export async function updateCollection(slug, updates) {
|
|
169
|
+
const schema = await getCollection(slug);
|
|
170
|
+
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
171
|
+
|
|
172
|
+
const { slug: _ignore, createdAt, ...rest } = updates;
|
|
173
|
+
const updated = {
|
|
174
|
+
...schema,
|
|
175
|
+
...rest,
|
|
176
|
+
slug,
|
|
177
|
+
createdAt: schema.createdAt,
|
|
178
|
+
updatedAt: new Date().toISOString()
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
await writeSchema(updated);
|
|
182
|
+
return updated;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Delete a collection and all its data.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} slug
|
|
189
|
+
* @returns {Promise<void>}
|
|
190
|
+
* @throws {Error} If collection not found
|
|
191
|
+
*/
|
|
192
|
+
export async function deleteCollection(slug) {
|
|
193
|
+
const schema = await getCollection(slug);
|
|
194
|
+
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
195
|
+
await fs.rm(collectionDir(slug), { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Entry operations
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Validate entry data against a collection schema.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} schema
|
|
206
|
+
* @param {object} data
|
|
207
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
208
|
+
*/
|
|
209
|
+
export function validateEntryData(schema, data) {
|
|
210
|
+
const errors = [];
|
|
211
|
+
for (const field of (schema.fields || [])) {
|
|
212
|
+
const val = data[field.name];
|
|
213
|
+
const isEmpty = val === undefined || val === null || val === '';
|
|
214
|
+
if (field.required && isEmpty) {
|
|
215
|
+
errors.push(`"${field.label || field.name}" is required`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { valid: errors.length === 0, errors };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* List entries with optional pagination, sorting, and search.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} slug
|
|
225
|
+
* @param {object} [opts]
|
|
226
|
+
* @param {number} [opts.page=1]
|
|
227
|
+
* @param {number} [opts.limit=50]
|
|
228
|
+
* @param {string} [opts.sort='createdAt']
|
|
229
|
+
* @param {string} [opts.order='desc']
|
|
230
|
+
* @param {string} [opts.search]
|
|
231
|
+
* @returns {Promise<{ entries: object[], total: number, page: number, limit: number }>}
|
|
232
|
+
*/
|
|
233
|
+
export async function listEntries(slug, { page = 1, limit = 50, sort = 'createdAt', order = 'desc', search } = {}) {
|
|
234
|
+
const schema = await getCollection(slug);
|
|
235
|
+
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
236
|
+
|
|
237
|
+
let entries = await readData(slug);
|
|
238
|
+
|
|
239
|
+
if (search) {
|
|
240
|
+
const term = search.toLowerCase();
|
|
241
|
+
entries = entries.filter(entry => {
|
|
242
|
+
return Object.values(entry.data || {}).some(v => String(v).toLowerCase().includes(term));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
entries.sort((a, b) => {
|
|
247
|
+
const aVal = sort === 'createdAt' ? a.meta?.createdAt : (a.data?.[sort] ?? '');
|
|
248
|
+
const bVal = sort === 'createdAt' ? b.meta?.createdAt : (b.data?.[sort] ?? '');
|
|
249
|
+
const cmp = String(aVal).localeCompare(String(bVal));
|
|
250
|
+
return order === 'desc' ? -cmp : cmp;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const total = entries.length;
|
|
254
|
+
const offset = (page - 1) * limit;
|
|
255
|
+
return { entries: entries.slice(offset, offset + limit), total, page, limit };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get a single entry by ID.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} slug
|
|
262
|
+
* @param {string} entryId
|
|
263
|
+
* @returns {Promise<object|null>}
|
|
264
|
+
*/
|
|
265
|
+
export async function getEntry(slug, entryId) {
|
|
266
|
+
const entries = await readData(slug);
|
|
267
|
+
return entries.find(e => e.id === entryId) || null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Create a new entry in a collection.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} slug
|
|
274
|
+
* @param {object} data - Field values keyed by field name
|
|
275
|
+
* @param {object} [meta]
|
|
276
|
+
* @param {string} [meta.createdBy]
|
|
277
|
+
* @param {string} [meta.source] - 'admin' | 'api' | 'import' | 'form:{slug}'
|
|
278
|
+
* @returns {Promise<object>} Created entry
|
|
279
|
+
* @throws {Error} If validation fails or collection not found
|
|
280
|
+
*/
|
|
281
|
+
export async function createEntry(slug, data, { createdBy = null, source = 'admin' } = {}) {
|
|
282
|
+
const schema = await getCollection(slug);
|
|
283
|
+
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
284
|
+
|
|
285
|
+
const { valid, errors } = validateEntryData(schema, data);
|
|
286
|
+
if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
|
|
287
|
+
|
|
288
|
+
const now = new Date().toISOString();
|
|
289
|
+
const entry = {
|
|
290
|
+
id: uuidv4(),
|
|
291
|
+
data,
|
|
292
|
+
meta: { createdAt: now, updatedAt: now, createdBy, source }
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const entries = await readData(slug);
|
|
296
|
+
entries.push(entry);
|
|
297
|
+
await writeData(slug, entries);
|
|
298
|
+
return entry;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Update an existing entry.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} slug
|
|
305
|
+
* @param {string} entryId
|
|
306
|
+
* @param {object} data
|
|
307
|
+
* @returns {Promise<object>} Updated entry
|
|
308
|
+
* @throws {Error} If entry not found or validation fails
|
|
309
|
+
*/
|
|
310
|
+
export async function updateEntry(slug, entryId, data) {
|
|
311
|
+
const schema = await getCollection(slug);
|
|
312
|
+
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
313
|
+
|
|
314
|
+
const entries = await readData(slug);
|
|
315
|
+
const idx = entries.findIndex(e => e.id === entryId);
|
|
316
|
+
if (idx === -1) throw new Error('Entry not found');
|
|
317
|
+
|
|
318
|
+
const { valid, errors } = validateEntryData(schema, data);
|
|
319
|
+
if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
|
|
320
|
+
|
|
321
|
+
entries[idx] = {
|
|
322
|
+
...entries[idx],
|
|
323
|
+
data,
|
|
324
|
+
meta: { ...entries[idx].meta, updatedAt: new Date().toISOString() }
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
await writeData(slug, entries);
|
|
328
|
+
return entries[idx];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Delete a single entry.
|
|
333
|
+
*
|
|
334
|
+
* @param {string} slug
|
|
335
|
+
* @param {string} entryId
|
|
336
|
+
* @returns {Promise<void>}
|
|
337
|
+
* @throws {Error} If entry not found
|
|
338
|
+
*/
|
|
339
|
+
export async function deleteEntry(slug, entryId) {
|
|
340
|
+
const entries = await readData(slug);
|
|
341
|
+
const idx = entries.findIndex(e => e.id === entryId);
|
|
342
|
+
if (idx === -1) throw new Error('Entry not found');
|
|
343
|
+
entries.splice(idx, 1);
|
|
344
|
+
await writeData(slug, entries);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Delete all entries from a collection.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} slug
|
|
351
|
+
* @returns {Promise<void>}
|
|
352
|
+
*/
|
|
353
|
+
export async function clearEntries(slug) {
|
|
354
|
+
const schema = await getCollection(slug);
|
|
355
|
+
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
356
|
+
await writeData(slug, []);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Import / Export
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Export collection entries as a JSON or CSV string.
|
|
365
|
+
*
|
|
366
|
+
* @param {string} slug
|
|
367
|
+
* @param {'json'|'csv'} [format='json']
|
|
368
|
+
* @returns {Promise<string>}
|
|
369
|
+
*/
|
|
370
|
+
export async function exportEntries(slug, format = 'json') {
|
|
371
|
+
const schema = await getCollection(slug);
|
|
372
|
+
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
373
|
+
const entries = await readData(slug);
|
|
374
|
+
|
|
375
|
+
if (format === 'csv') {
|
|
376
|
+
const fields = schema.fields.map(f => f.name);
|
|
377
|
+
const header = ['id', ...fields, 'createdAt'].join(',');
|
|
378
|
+
const rows = entries.map(e => {
|
|
379
|
+
const cells = ['id', ...fields, 'createdAt'].map(key => {
|
|
380
|
+
const val = key === 'id' ? e.id : key === 'createdAt' ? (e.meta?.createdAt || '') : (e.data?.[key] ?? '');
|
|
381
|
+
const str = String(val).replace(/"/g, '""');
|
|
382
|
+
return `"${str}"`;
|
|
383
|
+
});
|
|
384
|
+
return cells.join(',');
|
|
385
|
+
});
|
|
386
|
+
return [header, ...rows].join('\n');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return JSON.stringify(entries, null, 2);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Bulk-import entries into a collection.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} slug
|
|
396
|
+
* @param {object[]} incoming - Array of entries (must have at least a `data` key)
|
|
397
|
+
* @param {object} [opts]
|
|
398
|
+
* @param {string} [opts.createdBy]
|
|
399
|
+
* @returns {Promise<{ imported: number, skipped: number, errors: string[] }>}
|
|
400
|
+
*/
|
|
401
|
+
export async function importEntries(slug, incoming, { createdBy = null } = {}) {
|
|
402
|
+
const schema = await getCollection(slug);
|
|
403
|
+
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
404
|
+
|
|
405
|
+
const existing = await readData(slug);
|
|
406
|
+
const now = new Date().toISOString();
|
|
407
|
+
|
|
408
|
+
let imported = 0;
|
|
409
|
+
let skipped = 0;
|
|
410
|
+
const errors = [];
|
|
411
|
+
|
|
412
|
+
for (const item of incoming) {
|
|
413
|
+
const data = item.data || item;
|
|
414
|
+
const { valid, errors: valErrors } = validateEntryData(schema, data);
|
|
415
|
+
if (!valid) {
|
|
416
|
+
skipped++;
|
|
417
|
+
errors.push(valErrors.join('; '));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
existing.push({
|
|
421
|
+
id: uuidv4(),
|
|
422
|
+
data,
|
|
423
|
+
meta: { createdAt: now, updatedAt: now, createdBy, source: 'import' }
|
|
424
|
+
});
|
|
425
|
+
imported++;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
await writeData(slug, existing);
|
|
429
|
+
return { imported, skipped, errors };
|
|
430
|
+
}
|
|
@@ -7,6 +7,7 @@ import fs from 'fs/promises';
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {parseMarkdown, serialiseMarkdown} from './markdown.js';
|
|
9
9
|
import {config} from '../config.js';
|
|
10
|
+
import {hooks} from './hooks.js';
|
|
10
11
|
|
|
11
12
|
const CONTENT_DIR = config.content.contentDir;
|
|
12
13
|
const PAGES_DIR = path.join(CONTENT_DIR, 'pages');
|
|
@@ -68,12 +69,15 @@ export async function createPage(urlPath, frontmatter, body) {
|
|
|
68
69
|
showInNav: frontmatter.showInNav ?? false,
|
|
69
70
|
sidebar: frontmatter.sidebar ?? false,
|
|
70
71
|
seo: frontmatter.seo || {},
|
|
72
|
+
dconfig: frontmatter.dconfig || null,
|
|
71
73
|
createdAt: now,
|
|
72
74
|
updatedAt: now
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
|
|
76
|
-
|
|
78
|
+
const page = await readPageFile(filePath);
|
|
79
|
+
hooks.emit('content:pageCreated', {page, urlPath});
|
|
80
|
+
return page;
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
/**
|
|
@@ -98,7 +102,9 @@ export async function updatePage(urlPath, frontmatter, body) {
|
|
|
98
102
|
if (existingMeta.createdAt) meta.createdAt = existingMeta.createdAt;
|
|
99
103
|
|
|
100
104
|
await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
|
|
101
|
-
|
|
105
|
+
const page = await readPageFile(filePath);
|
|
106
|
+
hooks.emit('content:pageUpdated', {page, urlPath});
|
|
107
|
+
return page;
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
/**
|
|
@@ -125,6 +131,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
|
|
|
125
131
|
export async function deletePage(urlPath) {
|
|
126
132
|
const filePath = await resolveExistingFilePath(urlPath);
|
|
127
133
|
await fs.unlink(filePath);
|
|
134
|
+
hooks.emit('content:pageDeleted', {urlPath});
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
// ---------------------------------------------------------------------------
|
|
@@ -165,6 +172,7 @@ export async function saveMedia(filename, buffer) {
|
|
|
165
172
|
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
|
166
173
|
const filePath = path.join(MEDIA_DIR, filename);
|
|
167
174
|
await fs.writeFile(filePath, buffer);
|
|
175
|
+
hooks.emit('content:mediaUploaded', {filename});
|
|
168
176
|
return { name: filename, url: `/media/${filename}` };
|
|
169
177
|
}
|
|
170
178
|
|
|
@@ -177,6 +185,7 @@ export async function saveMedia(filename, buffer) {
|
|
|
177
185
|
export async function deleteMedia(filename) {
|
|
178
186
|
const filePath = path.join(MEDIA_DIR, filename);
|
|
179
187
|
await fs.unlink(filePath);
|
|
188
|
+
hooks.emit('content:mediaDeleted', {filename});
|
|
180
189
|
}
|
|
181
190
|
|
|
182
191
|
/**
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook System
|
|
3
|
+
* Provides three extensibility mechanisms for plugins:
|
|
4
|
+
* 1. EventEmitter — fire-and-forget content lifecycle events
|
|
5
|
+
* 2. Shortcode registry — register custom [shortcode] handlers at startup
|
|
6
|
+
* 3. Sanitize rule extensions — expand the HTML whitelist for plugin output
|
|
7
|
+
* 4. Transform pipeline — modify markdown/render values at named hook points
|
|
8
|
+
*/
|
|
9
|
+
import {EventEmitter} from 'node:events';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// 1. Event bus
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const hooks = new EventEmitter();
|
|
16
|
+
hooks.on('error', (err) => console.error('[hooks]', err));
|
|
17
|
+
|
|
18
|
+
export {hooks};
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// 2. Shortcode registry
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const _shortcodes = new Map(); // name -> { name, handler, priority }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register a shortcode handler.
|
|
28
|
+
* Handler signature: (attrStr, body, context) => string
|
|
29
|
+
* - body is null for self-closing shortcodes ([name attrs /])
|
|
30
|
+
* - context provides { parseShortcodeAttrs, scrubCodeRegions, marked, processCardBlocks, processGridBlocks }
|
|
31
|
+
*
|
|
32
|
+
* @param {string} name - Shortcode name (case-insensitive in regex, stored lowercase)
|
|
33
|
+
* @param {Function} handler
|
|
34
|
+
* @param {{ priority?: number }} options
|
|
35
|
+
*/
|
|
36
|
+
export function registerShortcode(name, handler, {priority = 10} = {}) {
|
|
37
|
+
_shortcodes.set(name.toLowerCase(), {name: name.toLowerCase(), handler, priority});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return all registered shortcode processors sorted by priority (ascending).
|
|
42
|
+
*
|
|
43
|
+
* @returns {{ name: string, handler: Function, priority: number }[]}
|
|
44
|
+
*/
|
|
45
|
+
export function getShortcodeProcessors() {
|
|
46
|
+
return [..._shortcodes.values()].sort((a, b) => a.priority - b.priority);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// 3. Sanitize rule extensions
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const _sanitizeExtensions = {tags: [], attributes: {}};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extend the HTML sanitizer whitelist with plugin-specific tags and attributes.
|
|
57
|
+
*
|
|
58
|
+
* @param {{ tags?: string[], attributes?: object }} rules
|
|
59
|
+
*/
|
|
60
|
+
export function registerSanitizeRules({tags = [], attributes = {}} = {}) {
|
|
61
|
+
_sanitizeExtensions.tags.push(...tags);
|
|
62
|
+
Object.assign(_sanitizeExtensions.attributes, attributes);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return the merged sanitize rule extensions.
|
|
67
|
+
*
|
|
68
|
+
* @returns {{ tags: string[], attributes: object }}
|
|
69
|
+
*/
|
|
70
|
+
export function getSanitizeExtensions() {
|
|
71
|
+
return _sanitizeExtensions;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// 4. Transform pipeline
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
const _transforms = new Map(); // hookName -> [{ fn, priority }]
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register a transform function at a named hook point.
|
|
82
|
+
* Available hook names: 'markdown:beforeParse', 'markdown:afterParse',
|
|
83
|
+
* 'render:beforeRender', 'render:afterRender'
|
|
84
|
+
*
|
|
85
|
+
* @param {string} hookName
|
|
86
|
+
* @param {Function} fn - (value, context) => value
|
|
87
|
+
* @param {{ priority?: number }} options
|
|
88
|
+
*/
|
|
89
|
+
export function registerTransform(hookName, fn, {priority = 10} = {}) {
|
|
90
|
+
if (!_transforms.has(hookName)) _transforms.set(hookName, []);
|
|
91
|
+
_transforms.get(hookName).push({fn, priority});
|
|
92
|
+
_transforms.get(hookName).sort((a, b) => a.priority - b.priority);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run all transforms registered at hookName against value, passing context.
|
|
97
|
+
* Returns the final transformed value.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} hookName
|
|
100
|
+
* @param {*} value
|
|
101
|
+
* @param {object} [context={}]
|
|
102
|
+
* @returns {*}
|
|
103
|
+
*/
|
|
104
|
+
export function applyTransforms(hookName, value, context = {}) {
|
|
105
|
+
for (const {fn} of (_transforms.get(hookName) || [])) {
|
|
106
|
+
value = fn(value, context);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|