domma-cms 0.66.0 → 0.69.4

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -1,6 +1,12 @@
1
+ import {fileURLToPath} from 'url';
1
2
  import defaultConfig from './config.js';
2
3
  import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
3
4
  import {getPluginStates, savePluginState} from '../../server/services/plugins.js';
5
+ import {renderBlogPage} from '../../server/services/renderer.js';
6
+ import {DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
7
+ import {catalogue, seedBlocks} from './lib/render.js';
8
+ import {liveSettings, loadEntries as loadAll, renderPostPage} from './lib/page.js';
9
+ import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
4
10
 
5
11
  const POSTS_SLUG = 'blog-posts';
6
12
  const CATEGORIES_SLUG = 'blog-categories';
@@ -16,6 +22,53 @@ function isEditorOrAdmin(user) {
16
22
  return canPublish(user);
17
23
  }
18
24
 
25
+ // Sidebar badge text helpers - kept here rather than imported from core, so the
26
+ // plugin still loads on an engine that predates server/services/sidebarBadges.js.
27
+ const plural = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`;
28
+ function clip(text, max) {
29
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim();
30
+ return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`;
31
+ }
32
+ function ago(iso) {
33
+ const secs = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
34
+ if (!Number.isFinite(secs)) return '';
35
+ if (secs < 60) return 'just now';
36
+ if (secs < 3600) return `${Math.round(secs / 60)} min ago`;
37
+ if (secs < 86400) return `${plural(Math.round(secs / 3600), 'hour')} ago`;
38
+ return `${plural(Math.round(secs / 86400), 'day')} ago`;
39
+ }
40
+
41
+ /** '' (the site default) or a post layout's block name. */
42
+ function cleanTemplate(value) {
43
+ const v = String(value ?? '').trim();
44
+ return /^blog-post-[a-z0-9-]+$/.test(v) ? v : '';
45
+ }
46
+
47
+ /**
48
+ * The fields a caller may set on a post. Status, publish date and authorship
49
+ * go through the publish / schedule routes, which check who is asking: taken
50
+ * straight from the body, a blog-author could publish their own post with
51
+ * `{"status": "published"}` and skip the editor entirely.
52
+ */
53
+ export function postFields(body, user) {
54
+ const out = {};
55
+ if (typeof body.title === 'string') out.title = body.title.trim();
56
+ if (typeof body.slug === 'string') out.slug = body.slug.trim();
57
+ if (typeof body.content === 'string') out.content = body.content;
58
+ if (typeof body.excerpt === 'string') out.excerpt = body.excerpt.trim();
59
+ if (body.featuredImage !== undefined) out.featuredImage = body.featuredImage ? String(body.featuredImage) : null;
60
+ if (Array.isArray(body.categories)) out.categories = body.categories.map(String);
61
+ if (Array.isArray(body.tags)) out.tags = body.tags.map(t => String(t).trim()).filter(Boolean);
62
+ if (body.seo && typeof body.seo === 'object') {
63
+ out.seo = {title: String(body.seo.title ?? ''), description: String(body.seo.description ?? '')};
64
+ }
65
+ if (body.template !== undefined) out.template = cleanTemplate(body.template);
66
+ // Only a publisher may move a post out of draft here (Save Draft sends 'draft').
67
+ if (body.status === 'draft') out.status = 'draft';
68
+ else if (body.status !== undefined && canPublish(user) && ['scheduled', 'published'].includes(body.status)) out.status = body.status;
69
+ return out;
70
+ }
71
+
19
72
  /** Derive a URL slug from a title string. */
20
73
  function slugify(str) {
21
74
  return String(str)
@@ -188,6 +241,120 @@ export default async function blogPlugin(fastify, options) {
188
241
  registerEmbedShortcodes(options.hooks.registerShortcode, basePath);
189
242
  }
190
243
 
244
+ // The layouts are blocks; put the shipped ones where the Blocks tool can
245
+ // edit them. Once each, never over a site's own copy (lib/render.js).
246
+ try {
247
+ const copied = await seedBlocks();
248
+ if (copied.length) fastify.log.info(`[blog] added layout blocks: ${copied.join(', ')}`);
249
+ } catch (err) {
250
+ fastify.log.warn(`[blog] could not add the layout blocks: ${err.message}`);
251
+ }
252
+
253
+ // -------------------------------------------------------------------------
254
+ // Layouts
255
+ // -------------------------------------------------------------------------
256
+
257
+ /** GET /api/plugins/blog/layouts - every post layout and listing display on offer */
258
+ fastify.get('/layouts', { preHandler: [authenticate] }, async () => {
259
+ const s = liveSettings(settings);
260
+ return { ...(await catalogue()), postLayout: s.postLayout, listDisplay: s.listDisplay };
261
+ });
262
+
263
+ /**
264
+ * POST /api/plugins/blog/preview - a post as the site would show it, from
265
+ * the editor's UNSAVED fields and in any layout, for the preview panel.
266
+ * The whole page, chrome and all, so what the author sees is what readers get.
267
+ */
268
+ fastify.post('/preview', { preHandler: [authenticate] }, async (request, reply) => {
269
+ const body = request.body ?? {};
270
+ const user = request.user;
271
+ let stored = null;
272
+ if (body.id) {
273
+ const entry = await getEntry(POSTS_SLUG, String(body.id));
274
+ if (entry) {
275
+ if (!isEditorOrAdmin(user) && entry.data.authorId !== (user?.id ?? user?.sub)) {
276
+ return reply.code(403).send({ error: 'Forbidden' });
277
+ }
278
+ stored = { id: entry.id, ...entry.data };
279
+ }
280
+ }
281
+ const post = {
282
+ id: stored?.id ?? '__preview__',
283
+ status: 'published',
284
+ publishedAt: stored?.publishedAt ?? new Date().toISOString(),
285
+ authorId: stored?.authorId ?? (user?.id ?? user?.sub ?? ''),
286
+ ...(stored ?? {}),
287
+ ...postFields(body, user)
288
+ };
289
+ post.status = 'published';
290
+ post.slug = post.slug || 'preview';
291
+ const allPosts = (await loadAll(POSTS_SLUG)).filter(p => p.id !== post.id);
292
+ const s = liveSettings(settings);
293
+ const page = await renderPostPage(post, [...allPosts, post], s, {
294
+ base: basePath, layout: cleanTemplate(body.layout) || null, preview: true
295
+ });
296
+ const html = await renderBlogPage(
297
+ fileURLToPath(new URL('./templates/post.html', import.meta.url)),
298
+ { post: page.html, assetV: '1.4.4' },
299
+ { title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
300
+ usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
301
+ );
302
+ return reply.send({ html, layout: page.layout });
303
+ });
304
+
305
+ // -------------------------------------------------------------------------
306
+ // Sample posts
307
+ // -------------------------------------------------------------------------
308
+
309
+ /**
310
+ * POST /api/plugins/blog/samples - add the sample posts (and any sample
311
+ * category the site lacks). A sample whose slug is taken is skipped, so
312
+ * pressing it twice adds nothing the second time.
313
+ */
314
+ fastify.post('/samples', { preHandler: [authenticate, requireAdmin] }, async (request) => {
315
+ const uid = request.user?.id ?? request.user?.sub ?? null;
316
+ const cats = await loadAll(CATEGORIES_SLUG);
317
+ const catIds = {};
318
+ for (const c of SAMPLE_CATEGORIES) {
319
+ const have = cats.find(x => x.slug === c.slug);
320
+ catIds[c.slug] = have ? have.id : (await createEntry(CATEGORIES_SLUG, { ...c, sample: true }, { createdBy: uid, source: 'admin' })).id;
321
+ }
322
+ const existing = new Set((await loadAll(POSTS_SLUG)).map(p => p.slug));
323
+ let added = 0;
324
+ for (const p of samplePosts()) {
325
+ if (existing.has(p.slug)) continue;
326
+ await createEntry(POSTS_SLUG, {
327
+ ...p,
328
+ categories: p.categories.map(slug => catIds[slug]).filter(Boolean),
329
+ template: p.template ?? '',
330
+ status: 'published',
331
+ authorId: uid,
332
+ sample: true,
333
+ seo: { title: '', description: '' }
334
+ }, { createdBy: uid, source: 'admin' });
335
+ added++;
336
+ }
337
+ return { ok: true, added };
338
+ });
339
+
340
+ /**
341
+ * DELETE /api/plugins/blog/samples - remove exactly what "Add" put there:
342
+ * posts marked as samples, and sample categories no remaining post uses.
343
+ */
344
+ fastify.delete('/samples', { preHandler: [authenticate, requireAdmin] }, async () => {
345
+ let posts = 0, categories = 0;
346
+ for (const p of (await loadAll(POSTS_SLUG)).filter(p => p.sample === true)) {
347
+ await deleteEntry(POSTS_SLUG, p.id);
348
+ posts++;
349
+ }
350
+ const inUse = new Set((await loadAll(POSTS_SLUG)).flatMap(p => p.categories ?? []));
351
+ for (const c of (await loadAll(CATEGORIES_SLUG)).filter(c => c.sample === true && !inUse.has(c.id))) {
352
+ await deleteEntry(CATEGORIES_SLUG, c.id);
353
+ categories++;
354
+ }
355
+ return { ok: true, posts, categories };
356
+ });
357
+
191
358
  // -------------------------------------------------------------------------
192
359
  // Posts
193
360
  // -------------------------------------------------------------------------
@@ -212,10 +379,7 @@ export default async function blogPlugin(fastify, options) {
212
379
  const uid = user?.id ?? user?.sub ?? null;
213
380
  const body = request.body ?? {};
214
381
 
215
- const {
216
- title, content, excerpt, featuredImage,
217
- categories, tags, status, slug
218
- } = body;
382
+ const { title, slug } = body;
219
383
 
220
384
  // The schema marks title, slug, authorId and status required, so an
221
385
  // empty title reaches createEntry and throws - a 500 where the
@@ -228,15 +392,18 @@ export default async function blogPlugin(fastify, options) {
228
392
  ? String(slug).trim()
229
393
  : slugify(title);
230
394
 
395
+ const fields = postFields(body, user);
231
396
  const entry = await createEntry(POSTS_SLUG, {
397
+ content: '',
398
+ excerpt: '',
399
+ featuredImage: null,
400
+ categories: [],
401
+ tags: [],
402
+ template: '',
403
+ ...fields,
232
404
  title: title.trim(),
233
405
  slug: derivedSlug,
234
- content: typeof content === 'string' ? content : '',
235
- excerpt: typeof excerpt === 'string' ? excerpt.trim() : '',
236
- featuredImage: featuredImage ?? null,
237
- categories: Array.isArray(categories) ? categories : [],
238
- tags: Array.isArray(tags) ? tags : [],
239
- status: status ?? 'draft',
406
+ status: fields.status ?? 'draft',
240
407
  authorId: uid
241
408
  }, { createdBy: uid, source: 'admin' });
242
409
 
@@ -263,8 +430,7 @@ export default async function blogPlugin(fastify, options) {
263
430
  return reply.code(403).send({ error: 'Forbidden' });
264
431
  }
265
432
 
266
- const updates = request.body ?? {};
267
- const merged = { ...entry.data, ...updates };
433
+ const merged = { ...entry.data, ...postFields(request.body ?? {}, user) };
268
434
 
269
435
  const updated = await updateEntry(POSTS_SLUG, id, merged);
270
436
  return reply.send(toPost(updated));
@@ -408,10 +574,49 @@ export default async function blogPlugin(fastify, options) {
408
574
  return reply.send(comments);
409
575
  });
410
576
 
577
+ /** GET /api/plugins/blog/comments/pending-count - the sidebar badge on Blog: comments waiting for approval */
578
+ fastify.get('/comments/pending-count', { preHandler: [authenticate] }, async () => {
579
+ const [comments, posts] = await Promise.all([loadAll(COMMENTS_SLUG), loadAll(POSTS_SLUG)]);
580
+ const pending = comments.filter(c => c.status === 'pending')
581
+ .sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')));
582
+ const titleOf = new Map(posts.map(p => [p.id, p.title || 'Untitled']));
583
+ const count = (status) => posts.filter(p => p.status === status).length;
584
+ const next = posts.filter(p => p.status === 'scheduled' && p.publishedAt)
585
+ .sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt)))[0];
586
+ const n = pending.length;
587
+ return {
588
+ count: n,
589
+ label: `${plural(n, 'comment')} waiting for approval`,
590
+ tone: 'attention',
591
+ details: {
592
+ title: 'Blog',
593
+ rows: [
594
+ { label: 'Waiting for approval', value: String(n) },
595
+ { label: 'Published', value: String(count('published')) },
596
+ { label: 'Drafts', value: String(count('draft')) },
597
+ { label: 'Scheduled', value: next ? `${count('scheduled')} · next ${String(next.publishedAt).slice(0, 10)}` : String(count('scheduled')) }
598
+ ],
599
+ // Plain text: the popover sets it with textContent, so a comment is never markup.
600
+ items: pending.slice(0, 4).map(c => ({
601
+ text: `${clip(c.authorName, 24)}: "${clip(c.body, 50)}"`,
602
+ meta: `on ${clip(titleOf.get(c.postId) ?? 'a deleted post', 36)} · ${ago(c.createdAt)}`
603
+ })),
604
+ empty: 'No comments waiting.'
605
+ }
606
+ };
607
+ });
608
+
411
609
  /** POST /api/plugins/blog/comments - public, no authenticate */
412
610
  fastify.post('/comments', async (request, reply) => {
413
611
  const body = request.body ?? {};
414
612
 
613
+ // Read the settings as they are NOW. `settings` above is captured when
614
+ // the plugin registers, so switching comments off - or moderation -
615
+ // did nothing until a restart, and a closed blog kept taking comments
616
+ // from anyone who posted straight to this route.
617
+ const live = { ...defaultConfig, ...(getPluginStates()?.blog?.settings ?? {}) };
618
+ if (!live.commentsEnabled) return reply.code(403).send({ error: 'Comments are closed' });
619
+
415
620
  // Honeypot: non-empty `website` field = bot, silently discard
416
621
  if (body.website) {
417
622
  return reply.code(201).send({ ok: true });
@@ -424,7 +629,7 @@ export default async function blogPlugin(fastify, options) {
424
629
  if (!authorEmail) return reply.code(400).send({ error: 'authorEmail is required' });
425
630
  if (!commentBody) return reply.code(400).send({ error: 'body is required' });
426
631
 
427
- const commentStatus = settings.commentModeration !== false ? 'pending' : 'approved';
632
+ const commentStatus = live.commentModeration !== false ? 'pending' : 'approved';
428
633
 
429
634
  const entry = await createEntry(COMMENTS_SLUG, {
430
635
  postId: String(postId),
@@ -482,7 +687,7 @@ export default async function blogPlugin(fastify, options) {
482
687
  /** GET /api/plugins/blog/settings */
483
688
  fastify.get('/settings', { preHandler: [authenticate] }, async (request, reply) => {
484
689
  const stored = getPluginStates()?.blog?.settings ?? {};
485
- return reply.send({ ...defaultConfig, ...stored });
690
+ return reply.send({ ...defaultConfig, ...DISPLAY_DEFAULTS, ...stored });
486
691
  });
487
692
 
488
693
  /** PUT /api/plugins/blog/settings */
@@ -493,13 +698,18 @@ export default async function blogPlugin(fastify, options) {
493
698
 
494
699
  // Whitelist rather than merge: every key here is read back by the
495
700
  // public routes, and storing whatever was posted invites surprises.
701
+ // Over what is stored, not over the defaults: a PUT carrying one field
702
+ // used to reset every other setting to its default.
703
+ const stored = getPluginStates()?.blog?.settings ?? {};
496
704
  const merged = {
497
705
  ...defaultConfig,
706
+ ...stored,
498
707
  ...(body.basePath !== undefined && { basePath: String(body.basePath) }),
499
708
  ...(body.postsPerPage !== undefined && { postsPerPage: Number(body.postsPerPage) || defaultConfig.postsPerPage }),
500
709
  ...(body.commentsEnabled !== undefined && { commentsEnabled: Boolean(body.commentsEnabled) }),
501
710
  ...(body.commentModeration !== undefined && { commentModeration: Boolean(body.commentModeration) }),
502
- ...(body.feedLength !== undefined && { feedLength: Number(body.feedLength) || defaultConfig.feedLength })
711
+ ...(body.feedLength !== undefined && { feedLength: Number(body.feedLength) || defaultConfig.feedLength }),
712
+ ...pickDisplaySettings(body)
503
713
  };
504
714
 
505
715
  savePluginState('blog', { settings: merged });
@@ -1,15 +1,14 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.1.0",
5
- "description": "Public-facing blog with posts, categories, and comments.",
4
+ "version": "1.4.4",
5
+ "tier": "free",
6
+ "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
6
7
  "author": "Domma CMS",
7
- "date": "2026-04-07",
8
+ "date": "2026-09-23",
8
9
  "icon": "file-text",
9
10
  "admin": {
10
- "css": [
11
- "admin/css/index.css"
12
- ],
11
+ "css": ["admin/css/index.css"],
13
12
  "sidebar": [
14
13
  {
15
14
  "id": "blog",
@@ -17,88 +16,29 @@
17
16
  "icon": "file-text",
18
17
  "url": "#/plugins/blog",
19
18
  "section": "#/plugins/blog",
19
+ "countUrl": "/api/plugins/blog/comments/pending-count",
20
20
  "children": [
21
- {
22
- "id": "blog-posts",
23
- "text": "Posts",
24
- "url": "#/plugins/blog/posts"
25
- },
26
- {
27
- "id": "blog-categories",
28
- "text": "Categories",
29
- "url": "#/plugins/blog/categories"
30
- },
31
- {
32
- "id": "blog-comments",
33
- "text": "Comments",
34
- "url": "#/plugins/blog/comments"
35
- },
36
- {
37
- "id": "blog-settings",
38
- "text": "Settings",
39
- "url": "#/plugins/blog/settings"
40
- }
21
+ { "id": "blog-posts", "text": "Posts", "url": "#/plugins/blog/posts" },
22
+ { "id": "blog-categories", "text": "Categories", "url": "#/plugins/blog/categories" },
23
+ { "id": "blog-comments", "text": "Comments", "url": "#/plugins/blog/comments" }
41
24
  ]
42
25
  }
43
26
  ],
44
27
  "routes": [
45
- {
46
- "path": "/plugins/blog",
47
- "view": "plugin-blog-posts",
48
- "title": "Blog - Domma CMS"
49
- },
50
- {
51
- "path": "/plugins/blog/posts",
52
- "view": "plugin-blog-posts",
53
- "title": "Blog Posts - Domma CMS"
54
- },
55
- {
56
- "path": "/plugins/blog/posts/new",
57
- "view": "plugin-blog-post-editor",
58
- "title": "New Post - Domma CMS"
59
- },
60
- {
61
- "path": "/plugins/blog/posts/:id",
62
- "view": "plugin-blog-post-editor",
63
- "title": "Edit Post - Domma CMS"
64
- },
65
- {
66
- "path": "/plugins/blog/categories",
67
- "view": "plugin-blog-categories",
68
- "title": "Blog Categories - Domma CMS"
69
- },
70
- {
71
- "path": "/plugins/blog/comments",
72
- "view": "plugin-blog-comments",
73
- "title": "Blog Comments - Domma CMS"
74
- },
75
- {
76
- "path": "/plugins/blog/settings",
77
- "view": "plugin-blog-settings",
78
- "title": "Blog Settings - Domma CMS"
79
- }
28
+ { "path": "/plugins/blog", "view": "plugin-blog-posts", "title": "Blog - Domma CMS" },
29
+ { "path": "/plugins/blog/posts", "view": "plugin-blog-posts", "title": "Blog Posts - Domma CMS" },
30
+ { "path": "/plugins/blog/posts/new", "view": "plugin-blog-post-editor", "title": "New Post - Domma CMS" },
31
+ { "path": "/plugins/blog/posts/:id", "view": "plugin-blog-post-editor", "title": "Edit Post - Domma CMS" },
32
+ { "path": "/plugins/blog/categories", "view": "plugin-blog-categories", "title": "Blog Categories - Domma CMS" },
33
+ { "path": "/plugins/blog/comments", "view": "plugin-blog-comments", "title": "Blog Comments - Domma CMS" },
34
+ { "path": "/plugins/blog/settings", "view": "plugin-blog-settings", "title": "Blog Settings - Domma CMS" }
80
35
  ],
81
36
  "views": {
82
- "plugin-blog-posts": {
83
- "entry": "blog/admin/views/blog.js?v=01f8b54e",
84
- "exportName": "blogView"
85
- },
86
- "plugin-blog-post-editor": {
87
- "entry": "blog/admin/views/post-editor.js?v=58c616ec",
88
- "exportName": "postEditorView"
89
- },
90
- "plugin-blog-categories": {
91
- "entry": "blog/admin/views/categories.js?v=17f4038c",
92
- "exportName": "categoriesView"
93
- },
94
- "plugin-blog-comments": {
95
- "entry": "blog/admin/views/comments.js?v=55dd8ad9",
96
- "exportName": "commentsView"
97
- },
98
- "plugin-blog-settings": {
99
- "entry": "blog/admin/views/settings.js?v=bd2310f3",
100
- "exportName": "settingsView"
101
- }
37
+ "plugin-blog-posts": { "entry": "blog/admin/views/blog.js?v=7", "exportName": "blogView" },
38
+ "plugin-blog-post-editor": { "entry": "blog/admin/views/post-editor.js?v=7", "exportName": "postEditorView" },
39
+ "plugin-blog-categories": { "entry": "blog/admin/views/categories.js?v=7", "exportName": "categoriesView" },
40
+ "plugin-blog-comments": { "entry": "blog/admin/views/comments.js?v=7", "exportName": "commentsView" },
41
+ "plugin-blog-settings": { "entry": "blog/admin/views/settings.js?v=7", "exportName": "settingsView" }
102
42
  }
103
43
  }
104
44
  }