domma-cms 0.9.0 → 0.9.5

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 (44) hide show
  1. package/admin/js/templates/block-editor.html +163 -163
  2. package/admin/js/templates/form-editor.html +245 -245
  3. package/admin/js/views/action-editor.js +1 -1
  4. package/admin/js/views/block-editor.js +8 -8
  5. package/admin/js/views/collection-editor.js +4 -4
  6. package/admin/js/views/collections.js +1 -1
  7. package/admin/js/views/form-editor.js +7 -7
  8. package/admin/js/views/forms.js +1 -1
  9. package/admin/js/views/navigation.js +14 -14
  10. package/admin/js/views/page-editor.js +35 -35
  11. package/admin/js/views/pages.js +5 -5
  12. package/admin/js/views/plugins.js +19 -10
  13. package/admin/js/views/view-editor.js +1 -1
  14. package/config/plugins.json +35 -0
  15. package/package.json +1 -1
  16. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +4 -4
  17. package/plugins/docs/data/folders.json +3 -3
  18. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +5 -0
  19. package/plugins/garage/admin/templates/garage.html +30 -0
  20. package/plugins/garage/admin/views/garage.js +62 -1
  21. package/plugins/garage/plugin.json +1 -1
  22. package/plugins/notes/admin/templates/notes.html +2 -11
  23. package/plugins/notes/admin/views/notes.js +107 -129
  24. package/plugins/notes/collections/user-notes/schema.json +2 -1
  25. package/plugins/notes/plugin.json +1 -1
  26. package/plugins/site-search/admin/templates/site-search.html +174 -46
  27. package/plugins/site-search/admin/views/site-search.js +72 -1
  28. package/plugins/site-search/config.js +6 -1
  29. package/plugins/site-search/plugin.json +1 -1
  30. package/plugins/site-search/public/inject-head.html +1 -1
  31. package/plugins/site-search/public/search.css +1 -1
  32. package/plugins/site-search/public/search.js +1 -1
  33. package/plugins/todo/admin/templates/todo.html +2 -8
  34. package/plugins/todo/admin/views/todo.js +122 -106
  35. package/plugins/todo/collections/todos/schema.json +2 -1
  36. package/plugins/todo/plugin.json +1 -1
  37. package/server/routes/api/media.js +127 -118
  38. package/server/routes/api/plugins.js +15 -4
  39. package/server/server.js +288 -285
  40. package/server/services/blocks.js +6 -3
  41. package/server/services/collections.js +17 -10
  42. package/server/services/plugins.js +77 -67
  43. package/server/services/renderer.js +3 -3
  44. package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +0 -11
@@ -37,7 +37,6 @@ function slugify(str) {
37
37
  async function readSchema(slug) {
38
38
  const raw = await fs.readFile(schemaPath(slug), 'utf8');
39
39
  const schema = JSON.parse(raw);
40
- // Normalise legacy 'preset' flag — both mean "bundled with fresh installs"
41
40
  if (schema.preset && !schema.bundled) {
42
41
  schema.bundled = true;
43
42
  delete schema.preset;
@@ -139,11 +138,11 @@ export async function createCollection({title, slug, description = '', fields =
139
138
  slug: finalSlug,
140
139
  title: title.trim(),
141
140
  description: description.trim(),
142
- ...(bundled ? {bundled: true} : {}),
143
- ...(plugin ? {plugin} : {}),
144
141
  fields,
145
142
  api: { ...defaultApiAccess(), ...api },
146
143
  storage: storage || {adapter: 'file'},
144
+ ...(bundled ? {bundled: true} : {}),
145
+ ...(plugin ? {plugin} : {}),
147
146
  createdAt: now,
148
147
  updatedAt: now
149
148
  };
@@ -165,20 +164,28 @@ export async function updateCollection(slug, updates) {
165
164
  const schema = await getCollection(slug);
166
165
  if (!schema) throw new Error(`Collection "${slug}" not found`);
167
166
 
168
- const { slug: _ignore, createdAt, plugin: _plugin, bundled: _bundled, ...rest } = updates;
167
+ const {slug: _ignore, createdAt, plugin: _stripPlugin, ...rest} = updates;
169
168
  const updated = {
170
169
  ...schema,
171
170
  ...rest,
172
- // bundled is user-editable — set from update, omit if falsy
173
- ...(updates.bundled ? {bundled: true} : {}),
174
- // plugin is ownership metadata — never overwrite from updates
175
- ...(schema.plugin ? {plugin: schema.plugin} : {}),
176
171
  slug,
177
172
  createdAt: schema.createdAt,
178
173
  updatedAt: new Date().toISOString()
179
174
  };
180
- // Clear bundled from schema if it was unchecked (schema spread may have preserved old value)
181
- if (!updates.bundled) delete updated.bundled;
175
+
176
+ // Preserve the original plugin field (never overwrite)
177
+ if (schema.plugin) {
178
+ updated.plugin = schema.plugin;
179
+ } else {
180
+ delete updated.plugin;
181
+ }
182
+
183
+ // Allow bundled to be set or cleared
184
+ if (updates.bundled) {
185
+ updated.bundled = true;
186
+ } else {
187
+ delete updated.bundled;
188
+ }
182
189
 
183
190
  await writeSchema(updated);
184
191
 
@@ -233,8 +233,7 @@ export async function getInjectionSnippets() {
233
233
  }
234
234
 
235
235
  /**
236
- * Recursively collect all .md files under a directory.
237
- * Returns an empty array if the directory does not exist.
236
+ * Recursively walk a directory and return all .md file paths.
238
237
  *
239
238
  * @param {string} dir
240
239
  * @returns {Promise<string[]>}
@@ -256,60 +255,65 @@ async function walkMdFiles(dir) {
256
255
  }
257
256
 
258
257
  /**
259
- * Set up all resources owned by a plugin: collections, forms, and pages.
260
- * Each resource type is created only if it does not already exist (idempotent).
261
- * Called automatically before `onEnable`.
258
+ * Set up plugin-owned resources (collections, forms, pages) when a plugin is enabled.
259
+ * Only creates resources that do not already exist.
262
260
  *
263
261
  * @param {string} pluginName
264
- * @param {object} services - Service map from runLifecycleHook
265
- * @returns {Promise<{collections: string[], forms: string[], pages: string[]}>}
262
+ * @param {object} services
263
+ * @returns {Promise<{ collections: string[], forms: string[], pages: string[] }>}
266
264
  */
267
265
  export async function setupPlugin(pluginName, services) {
268
- const pluginDir = path.join(PLUGINS_DIR, pluginName);
269
266
  const result = {collections: [], forms: [], pages: []};
267
+ const pluginDir = path.join(PLUGINS_DIR, pluginName);
270
268
 
271
269
  // Collections
272
270
  if (services.collections) {
273
- let entries = [];
271
+ const collectionsDir = path.join(pluginDir, 'collections');
272
+ let collectionDirs;
274
273
  try {
275
- entries = await fs.readdir(path.join(pluginDir, 'collections'), {withFileTypes: true});
276
- } catch { /* no collections dir */ }
277
- for (const entry of entries.filter(e => e.isDirectory())) {
278
- const schemaPath = path.join(pluginDir, 'collections', entry.name, 'schema.json');
279
- let schema;
274
+ collectionDirs = await fs.readdir(collectionsDir, {withFileTypes: true});
275
+ } catch {
276
+ collectionDirs = [];
277
+ }
278
+ for (const entry of collectionDirs.filter(e => e.isDirectory())) {
279
+ const schemaFile = path.join(collectionsDir, entry.name, 'schema.json');
280
280
  try {
281
- schema = JSON.parse(await fs.readFile(schemaPath, 'utf8'));
281
+ const raw = await fs.readFile(schemaFile, 'utf8');
282
+ const schema = JSON.parse(raw);
283
+ schema.plugin = pluginName;
284
+ const existing = await services.collections.getCollection(schema.slug).catch(() => null);
285
+ if (!existing) {
286
+ await services.collections.createCollection(schema);
287
+ result.collections.push(schema.slug);
288
+ }
282
289
  } catch {
283
- continue;
284
- }
285
- schema.plugin = pluginName;
286
- const existing = await services.collections.getCollection(schema.slug).catch(() => null);
287
- if (!existing) {
288
- await services.collections.createCollection(schema);
289
- result.collections.push(schema.slug);
290
+ // Skip missing or invalid schema files
290
291
  }
291
292
  }
292
293
  }
293
294
 
294
295
  // Forms
295
296
  if (services.forms) {
296
- let entries = [];
297
+ const formsDir = path.join(pluginDir, 'forms');
298
+ let formFiles;
297
299
  try {
298
- entries = await fs.readdir(path.join(pluginDir, 'forms'));
299
- } catch { /* no forms dir */ }
300
- for (const file of entries.filter(e => e.endsWith('.json'))) {
301
- let data;
300
+ formFiles = await fs.readdir(formsDir);
301
+ } catch {
302
+ formFiles = [];
303
+ }
304
+ for (const file of formFiles.filter(f => f.endsWith('.json'))) {
302
305
  try {
303
- data = JSON.parse(await fs.readFile(path.join(pluginDir, 'forms', file), 'utf8'));
306
+ const raw = await fs.readFile(path.join(formsDir, file), 'utf8');
307
+ const data = JSON.parse(raw);
308
+ data.plugin = pluginName;
309
+ try {
310
+ await services.forms.createForm(data);
311
+ result.forms.push(data.slug || file);
312
+ } catch (err) {
313
+ if (err.code !== 'FORM_ALREADY_EXISTS') throw err;
314
+ }
304
315
  } catch {
305
- continue;
306
- }
307
- data.plugin = pluginName;
308
- try {
309
- await services.forms.createForm(data);
310
- result.forms.push(data.slug || data.title);
311
- } catch (err) {
312
- if (err.code !== 'FORM_ALREADY_EXISTS') throw err;
316
+ // Skip missing or invalid form files
313
317
  }
314
318
  }
315
319
  }
@@ -317,22 +321,22 @@ export async function setupPlugin(pluginName, services) {
317
321
  // Pages
318
322
  if (services.content) {
319
323
  const pagesDir = path.join(pluginDir, 'pages');
320
- const pageFiles = await walkMdFiles(pagesDir);
321
- for (const filePath of pageFiles) {
322
- let raw;
324
+ const mdFiles = await walkMdFiles(pagesDir);
325
+ for (const filePath of mdFiles) {
323
326
  try {
324
- raw = await fs.readFile(filePath, 'utf8');
327
+ const raw = await fs.readFile(filePath, 'utf8');
328
+ const parsed = matter(raw);
329
+ const rel = path.relative(pagesDir, filePath);
330
+ let urlPath = '/' + rel.replace(/\\/g, '/').replace(/\.md$/, '').replace(/\/index$/, '');
331
+ if (urlPath !== '/' && urlPath.endsWith('/')) urlPath = urlPath.slice(0, -1);
332
+ parsed.data.plugin = pluginName;
333
+ const existing = await services.content.getPage(urlPath).catch(() => null);
334
+ if (!existing) {
335
+ await services.content.createPage(urlPath, parsed.data, parsed.content.trim());
336
+ result.pages.push(urlPath);
337
+ }
325
338
  } catch {
326
- continue;
327
- }
328
- const parsed = matter(raw);
329
- const rel = path.relative(pagesDir, filePath).replace(/\\/g, '/');
330
- const urlPath = '/' + rel.replace(/\.md$/, '').replace(/\/index$/, '').replace(/^index$/, '');
331
- parsed.data.plugin = pluginName;
332
- const existing = await services.content.getPage(urlPath);
333
- if (!existing) {
334
- await services.content.createPage(urlPath, parsed.data, parsed.content.trim());
335
- result.pages.push(urlPath);
339
+ // Skip unreadable or invalid page files
336
340
  }
337
341
  }
338
342
  }
@@ -341,43 +345,52 @@ export async function setupPlugin(pluginName, services) {
341
345
  }
342
346
 
343
347
  /**
344
- * Remove all pages and forms owned by a plugin.
345
- * Collections are intentionally preserved to protect stored data.
346
- * Called automatically before `onDisable`.
348
+ * Tear down plugin-owned pages and forms when a plugin is disabled.
349
+ * Collections are NOT deleted to preserve data.
347
350
  *
348
351
  * @param {string} pluginName
349
- * @param {object} services - Service map from runLifecycleHook
350
- * @param {import('fastify').FastifyInstance} [fastify]
351
- * @returns {Promise<{forms: string[], pages: string[]}>}
352
+ * @param {object} services
353
+ * @param {import('fastify').FastifyInstance} fastify
354
+ * @returns {Promise<{ pages: string[], forms: string[] }>}
352
355
  */
353
356
  export async function teardownPlugin(pluginName, services, fastify) {
354
- const removed = {forms: [], pages: []};
357
+ const result = {pages: [], forms: []};
355
358
 
359
+ // Pages
356
360
  if (services.content) {
357
361
  try {
358
362
  const pages = await services.content.listPages();
359
363
  for (const page of pages.filter(p => p.plugin === pluginName)) {
360
- await services.content.deletePage(page.urlPath);
361
- removed.pages.push(page.urlPath);
364
+ try {
365
+ await services.content.deletePage(page.urlPath);
366
+ result.pages.push(page.urlPath);
367
+ } catch (err) {
368
+ fastify.log.warn(`[plugins] Could not remove page "${page.urlPath}" for "${pluginName}": ${err.message}`);
369
+ }
362
370
  }
363
371
  } catch (err) {
364
- fastify?.log.warn(`[plugins] Could not remove pages for "${pluginName}": ${err.message}`);
372
+ fastify.log.warn(`[plugins] Could not list pages during teardown of "${pluginName}": ${err.message}`);
365
373
  }
366
374
  }
367
375
 
376
+ // Forms
368
377
  if (services.forms) {
369
378
  try {
370
379
  const forms = await services.forms.listForms();
371
380
  for (const form of forms.filter(f => f.plugin === pluginName)) {
372
- await services.forms.deleteForm(form.slug);
373
- removed.forms.push(form.slug);
381
+ try {
382
+ await services.forms.deleteForm(form.slug);
383
+ result.forms.push(form.slug);
384
+ } catch (err) {
385
+ fastify.log.warn(`[plugins] Could not remove form "${form.slug}" for "${pluginName}": ${err.message}`);
386
+ }
374
387
  }
375
388
  } catch (err) {
376
- fastify?.log.warn(`[plugins] Could not remove forms for "${pluginName}": ${err.message}`);
389
+ fastify.log.warn(`[plugins] Could not list forms during teardown of "${pluginName}": ${err.message}`);
377
390
  }
378
391
  }
379
392
 
380
- return removed;
393
+ return result;
381
394
  }
382
395
 
383
396
  /**
@@ -390,7 +403,6 @@ export async function teardownPlugin(pluginName, services, fastify) {
390
403
  * @param {import('fastify').FastifyInstance} fastify
391
404
  * @returns {Promise<void>}
392
405
  */
393
-
394
406
  export async function runLifecycleHook(name, hook, fastify) {
395
407
  // Validate hook name
396
408
  if (!['onEnable', 'onDisable'].includes(hook)) return;
@@ -398,7 +410,6 @@ export async function runLifecycleHook(name, hook, fastify) {
398
410
  const pluginJsPath = path.join(PLUGINS_DIR, name, 'plugin.js');
399
411
  try {
400
412
  const mod = await import(pluginJsPath);
401
- if (typeof mod[hook] !== 'function') return;
402
413
 
403
414
  // Use Promise.allSettled to prevent total failure if any service import rejects
404
415
  // (e.g. MongoDB-dependent modules like actions.js or views.js)
@@ -441,7 +452,6 @@ export async function runLifecycleHook(name, hook, fastify) {
441
452
  if (setup.forms.length) fastify.log.info(`[plugins] Created forms for "${name}": ${setup.forms.join(', ')}`);
442
453
  if (setup.pages.length) fastify.log.info(`[plugins] Created pages for "${name}": ${setup.pages.join(', ')}`);
443
454
  }
444
-
445
455
  if (hook === 'onDisable') {
446
456
  const torn = await teardownPlugin(name, services, fastify);
447
457
  if (torn.pages.length) fastify.log.info(`[plugins] Removed pages for "${name}": ${torn.pages.join(', ')}`);
@@ -68,9 +68,9 @@ export async function renderPage(page) {
68
68
 
69
69
  const preset = presets[page.layout] || presets['default'] || {};
70
70
 
71
- const seoTitle = page.seo?.title || `${page.title}${site.seo?.titleSeparator || ' | '}${site.seo?.defaultTitle || site.title}`;
72
- const seoDescription = page.seo?.description || site.seo?.defaultDescription || '';
73
- const ogImage = page.seo?.image || site.seo?.defaultImage || '';
71
+ const seoTitle = escapeHtml(page.seo?.title || `${page.title}${site.seo?.titleSeparator || ' | '}${site.seo?.defaultTitle || site.title}`);
72
+ const seoDescription = escapeHtml(page.seo?.description || site.seo?.defaultDescription || '');
73
+ const ogImage = escapeHtml(page.seo?.image || site.seo?.defaultImage || '');
74
74
 
75
75
  const dconfig = page.dconfig || null;
76
76
  // Escape </script> to prevent injection via dconfig values in the inline script block
@@ -1,11 +0,0 @@
1
- {
2
- "id": "452f49b7-9c93-4a67-874d-27f882891ad2",
3
- "title": "Untitled",
4
- "content": "",
5
- "folderId": null,
6
- "tags": [],
7
- "userId": "2421ad8e-060d-4548-8878-af7011d5e08b",
8
- "wordCount": 0,
9
- "createdAt": "2026-03-24T16:48:26.074Z",
10
- "updatedAt": "2026-03-24T16:49:05.067Z"
11
- }