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.
Files changed (87) hide show
  1. package/admin/css/admin.css +78 -1
  2. package/admin/js/api.js +32 -0
  3. package/admin/js/app.js +24 -7
  4. package/admin/js/config/sidebar-config.js +8 -0
  5. package/admin/js/templates/collection-editor.html +80 -0
  6. package/admin/js/templates/collection-entries.html +36 -0
  7. package/admin/js/templates/collections.html +12 -0
  8. package/admin/js/templates/documentation.html +136 -0
  9. package/admin/js/templates/navigation.html +26 -4
  10. package/admin/js/templates/page-editor.html +91 -85
  11. package/admin/js/templates/settings.html +433 -172
  12. package/admin/js/views/collection-editor.js +487 -0
  13. package/admin/js/views/collection-entries.js +484 -0
  14. package/admin/js/views/collections.js +153 -0
  15. package/admin/js/views/dashboard.js +14 -6
  16. package/admin/js/views/index.js +9 -3
  17. package/admin/js/views/login.js +3 -2
  18. package/admin/js/views/navigation.js +77 -11
  19. package/admin/js/views/page-editor.js +207 -25
  20. package/admin/js/views/pages.js +14 -6
  21. package/admin/js/views/settings.js +137 -2
  22. package/admin/js/views/users.js +10 -7
  23. package/bin/cli.js +37 -10
  24. package/config/auth.json +2 -1
  25. package/config/content.json +1 -0
  26. package/config/navigation.json +14 -4
  27. package/config/plugins.json +0 -18
  28. package/config/presets.json +4 -8
  29. package/config/site.json +44 -3
  30. package/package.json +6 -2
  31. package/plugins/domma-effects/admin/templates/domma-effects.html +92 -3
  32. package/plugins/domma-effects/plugin.js +125 -0
  33. package/plugins/domma-effects/public/inject-body.html +19 -0
  34. package/plugins/example-analytics/admin/views/analytics.js +2 -2
  35. package/plugins/example-analytics/plugin.json +8 -0
  36. package/plugins/example-analytics/stats.json +15 -1
  37. package/plugins/form-builder/admin/templates/form-editor.html +19 -6
  38. package/plugins/form-builder/admin/views/form-editor.js +634 -9
  39. package/plugins/form-builder/admin/views/form-submissions.js +4 -4
  40. package/plugins/form-builder/admin/views/forms-list.js +5 -5
  41. package/plugins/form-builder/data/forms/consent.json +104 -0
  42. package/plugins/form-builder/data/forms/contacts.json +66 -0
  43. package/plugins/form-builder/data/submissions/consent.json +13 -0
  44. package/plugins/form-builder/data/submissions/contacts.json +26 -0
  45. package/plugins/form-builder/plugin.js +62 -11
  46. package/plugins/form-builder/plugin.json +12 -16
  47. package/plugins/form-builder/public/form-logic-engine.js +568 -0
  48. package/plugins/form-builder/public/inject-body.html +88 -6
  49. package/plugins/form-builder/public/inject-head.html +16 -0
  50. package/plugins/form-builder/public/package.json +1 -0
  51. package/public/css/site.css +113 -0
  52. package/public/js/btt.js +90 -0
  53. package/public/js/cookie-consent.js +61 -0
  54. package/public/js/site.js +129 -34
  55. package/scripts/build.js +129 -0
  56. package/scripts/seed.js +517 -7
  57. package/server/routes/api/collections.js +301 -0
  58. package/server/routes/api/settings.js +66 -2
  59. package/server/server.js +19 -15
  60. package/server/services/collections.js +430 -0
  61. package/server/services/content.js +11 -2
  62. package/server/services/hooks.js +109 -0
  63. package/server/services/markdown.js +500 -149
  64. package/server/services/plugins.js +6 -1
  65. package/server/services/renderer.js +73 -7
  66. package/server/templates/page.html +38 -3
  67. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +0 -55
  68. package/plugins/back-to-top/admin/views/back-to-top-settings.js +0 -44
  69. package/plugins/back-to-top/config.js +0 -10
  70. package/plugins/back-to-top/plugin.js +0 -24
  71. package/plugins/back-to-top/plugin.json +0 -36
  72. package/plugins/back-to-top/public/inject-body.html +0 -105
  73. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +0 -113
  74. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +0 -73
  75. package/plugins/cookie-consent/config.js +0 -30
  76. package/plugins/cookie-consent/plugin.js +0 -24
  77. package/plugins/cookie-consent/plugin.json +0 -36
  78. package/plugins/cookie-consent/public/inject-body.html +0 -69
  79. package/plugins/custom-css/admin/templates/custom-css.html +0 -17
  80. package/plugins/custom-css/admin/views/custom-css.js +0 -35
  81. package/plugins/custom-css/config.js +0 -1
  82. package/plugins/custom-css/data/custom.css +0 -0
  83. package/plugins/custom-css/plugin.js +0 -63
  84. package/plugins/custom-css/plugin.json +0 -32
  85. package/plugins/custom-css/public/inject-head.html +0 -1
  86. package/plugins/form-builder/data/forms/contact.json +0 -52
  87. 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
- return readPageFile(filePath);
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
- return readPageFile(filePath);
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
+ }