domma-cms 0.55.1 → 0.69.3

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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Sample posts - a blog to look at before there is one.
3
+ *
4
+ * Every sample post and category carries `sample: true`, so "Remove sample
5
+ * posts" takes away exactly what "Add sample posts" put there and nothing an
6
+ * author wrote. A sample category an author has since filed a real post under
7
+ * is left alone.
8
+ *
9
+ * The posts are spread over the four shipped layouts and use the covers in
10
+ * public/samples/, so nothing is hotlinked from elsewhere.
11
+ *
12
+ * @module blog/lib/samples
13
+ */
14
+
15
+ const IMG = (name) => `/plugins/blog/public/samples/${name}.svg`;
16
+
17
+ export const SAMPLE_CATEGORIES = [
18
+ {name: 'Guides', slug: 'guides', description: 'How to get things done, step by step.'},
19
+ {name: 'News', slug: 'news', description: 'What is new, and what changed.'},
20
+ {name: 'Design', slug: 'design', description: 'Notes on layout, type and colour.'},
21
+ {name: 'Engineering', slug: 'engineering', description: 'How it works under the bonnet.'}
22
+ ];
23
+
24
+ const DAY = 86_400_000;
25
+
26
+ /**
27
+ * @param {number} daysAgo
28
+ * @param {number} [hour]
29
+ */
30
+ const ago = (daysAgo, hour = 9) => {
31
+ const d = new Date(Date.now() - daysAgo * DAY);
32
+ d.setUTCHours(hour, 0, 0, 0);
33
+ return d.toISOString();
34
+ };
35
+
36
+ /** Posts reference categories by slug here; the installer maps them to ids. */
37
+ export function samplePosts() {
38
+ return [
39
+ {
40
+ title: 'Welcome to the new blog',
41
+ slug: 'welcome-to-the-new-blog',
42
+ excerpt: 'Layouts you can choose per post, listings you can filter with a right-click, and every template editable as a block.',
43
+ categories: ['news'], tags: ['announcement', 'blog'],
44
+ featuredImage: IMG('aurora'), template: 'blog-post-feature', publishedAt: ago(1),
45
+ content: `The blog has had a proper overhaul. Every post can now choose how it looks, every listing can be searched and filtered without leaving the page, and all of it is built from blocks you can edit.
46
+
47
+ ## Layouts, per post
48
+
49
+ Open any post in the editor and pick a layout from the **Layout** card: *Classic*, *Feature* (this one), *Minimal* or *Sidebar*. The site-wide default lives in the Blog settings, behind the cog.
50
+
51
+ ## Listings that answer back
52
+
53
+ The blog index is no longer a static list. Search it, narrow it to a category with the pills, or **right-click any post** for "More in this category", "More tagged…" and "More by this author".
54
+
55
+ ## Everything is a block
56
+
57
+ The layouts are ordinary blocks called \`blog-post-*\` and \`blog-card-*\`. Change one in the Blocks tool and every post using it changes with it. Copy one under a new name and it appears as a new choice.
58
+
59
+ > Start with the Blocks tool, not the code. That is the point.
60
+
61
+ Enjoy it - and delete these sample posts from the Posts menu whenever you are ready.`
62
+ },
63
+ {
64
+ title: 'Writing your first post, start to finish',
65
+ slug: 'writing-your-first-post',
66
+ excerpt: 'From a blank editor to a published, well-linked post in about ten minutes.',
67
+ categories: ['guides'], tags: ['writing', 'getting-started'],
68
+ featuredImage: IMG('meadow'), template: 'blog-post-sidebar', publishedAt: ago(4),
69
+ content: `This guide walks through a post from the first word to the Publish button. The contents list beside it follows you down the page.
70
+
71
+ ## Start with the title
72
+
73
+ A good title says what the reader gets. "Ten minutes to your first post" beats "Some thoughts on writing". The slug fills itself in from the title while **Auto** is ticked.
74
+
75
+ ## Write the excerpt
76
+
77
+ The excerpt is the sentence or two shown on listings and in search results. Write it last if that is easier - it is often clearer once the post exists.
78
+
79
+ ## The body
80
+
81
+ Posts are Markdown. Headings (\`##\`) become the contents list in the Sidebar layout, so use them for the real sections of your argument.
82
+
83
+ ### Links and images
84
+
85
+ Link generously to your own older posts. Pick a featured image from the Media library; it appears on listings and at the top of most layouts.
86
+
87
+ ### Shortcodes
88
+
89
+ Every shortcode the site supports works in a post, including blocks.
90
+
91
+ ## Categories and tags
92
+
93
+ Categories are the few big shelves your blog has. Tags are the many small labels. A reader can right-click any post on the listing to see more with the same tag.
94
+
95
+ ## Publish, or schedule
96
+
97
+ **Publish now** puts it live. **Schedule** holds it until the date you choose - no need to be awake at 7am.`
98
+ },
99
+ {
100
+ title: 'Why whitespace is a feature',
101
+ slug: 'why-whitespace-is-a-feature',
102
+ excerpt: 'Room to breathe is not wasted space. It is how a reader knows what matters.',
103
+ categories: ['design'], tags: ['typography', 'layout'],
104
+ featuredImage: IMG('dusk'), template: 'blog-post-minimal', publishedAt: ago(9),
105
+ content: `Every page makes an argument about what matters, and whitespace is how it makes it.
106
+
107
+ Crowd two things together and the reader assumes they belong together. Put room between them and they read as separate thoughts. Layout is grammar.
108
+
109
+ ## Measure
110
+
111
+ A line of text is most comfortable at somewhere between 55 and 75 characters. Wider, and the eye loses its place on the way back. This layout keeps to a narrow column on purpose.
112
+
113
+ ## Rhythm
114
+
115
+ Consistent spacing - the same gap after every paragraph, the same before every heading - is what makes a page feel calm. Inconsistent spacing is noise the reader cannot name but can feel.
116
+
117
+ ## Restraint
118
+
119
+ The hardest part of design is leaving things out. This layout has no featured image and no sidebar. It has the words, and room around them.`
120
+ },
121
+ {
122
+ title: 'How the listing filters work',
123
+ slug: 'how-the-listing-filters-work',
124
+ excerpt: 'The blog index filters in the browser with domma-reactive - here is what happens between the click and the cards.',
125
+ categories: ['engineering'], tags: ['domma-reactive', 'javascript', 'blog'],
126
+ featuredImage: IMG('harbour'), publishedAt: ago(15),
127
+ content: `The listing you came from is rendered on the server, so it works without JavaScript and search engines see every post. The script then takes it over.
128
+
129
+ ## One model
130
+
131
+ Each card carries a small bundle of plain values - title, date, categories, tags, author. The script reads those into a list and keeps the filters in a handful of observables: the search text, the category, the tag, the author and the sort order.
132
+
133
+ ## Derived, not managed
134
+
135
+ What is shown is a *computed* value: filter the list, sort it, take the first page. Nothing hides or shows a card by hand; an effect applies whatever the computed says. Change any filter and everything downstream follows.
136
+
137
+ ## The right-click menu
138
+
139
+ Right-clicking a card opens a menu built from that card's own values. "More in Design" simply sets the category observable. The chips above the list are the same observables, drawn the other way round.
140
+
141
+ ## The address bar
142
+
143
+ Filters are written back into the address, so a filtered view is a link you can share.`
144
+ },
145
+ {
146
+ title: 'Five small things that make a site feel fast',
147
+ slug: 'five-things-that-make-a-site-feel-fast',
148
+ excerpt: 'Speed is partly measured and partly perceived. These five help with both.',
149
+ categories: ['engineering', 'design'], tags: ['performance'],
150
+ featuredImage: IMG('ember'), publishedAt: ago(23),
151
+ content: `A site can be quick on paper and still feel slow. These are the small things that close the gap.
152
+
153
+ ## 1. Reserve space for images
154
+
155
+ Give images their proportions up front so the page does not jump as they arrive. Cards here use a fixed aspect ratio for exactly that reason.
156
+
157
+ ## 2. Lazy-load what is below the fold
158
+
159
+ The browser does this for you with \`loading="lazy"\`. Cards use it; the image at the top of a post does not, because it is the first thing you see.
160
+
161
+ ## 3. Respond instantly, finish later
162
+
163
+ Filtering the blog listing happens in the browser, so the cards change the moment you type - there is no round trip to wait for.
164
+
165
+ ## 4. Compress the right things
166
+
167
+ Text compresses beautifully. Already-compressed files - images, archives - should be sent as they are.
168
+
169
+ ## 5. Do less
170
+
171
+ The fastest code is the code that never runs. Every feature should earn its bytes.`
172
+ },
173
+ {
174
+ title: 'Choosing categories that last',
175
+ slug: 'choosing-categories-that-last',
176
+ excerpt: 'A handful of broad, stable categories and a generous sprinkling of tags will outlive any clever taxonomy.',
177
+ categories: ['guides'], tags: ['writing', 'organisation'],
178
+ featuredImage: IMG('rosewood'), publishedAt: ago(31),
179
+ content: `Categories are the shelves; tags are the labels on the books. Get the shelves right and the rest looks after itself.
180
+
181
+ ## Keep them few
182
+
183
+ Four to eight categories is plenty for most blogs. More than that and authors start guessing, and readers stop trusting the pills at the top of the listing.
184
+
185
+ ## Make them stable
186
+
187
+ A category should still make sense in two years. "News" will. "Spring 2026 launch" will not - that is a tag.
188
+
189
+ ## Let tags do the detail
190
+
191
+ Tags can be as specific as you like. Readers find them by right-clicking a post and choosing "More tagged…", so a precise tag is genuinely useful.`
192
+ },
193
+ {
194
+ title: 'Release notes: comments, moderation and more',
195
+ slug: 'release-notes-comments-and-moderation',
196
+ excerpt: 'Comments now respect their settings the moment you change them, and moderation gained a Rejected list.',
197
+ categories: ['news'], tags: ['release', 'comments'],
198
+ featuredImage: IMG('harbour'), publishedAt: ago(40),
199
+ content: `A round-up of the changes to comments.
200
+
201
+ ## Settings take effect immediately
202
+
203
+ Switching comments off now closes them straight away - no restart. Holding comments for approval works the same way.
204
+
205
+ ## A place for rejected comments
206
+
207
+ Rejected comments used to vanish from view. They now have their own list, so a mistaken rejection can be undone.
208
+
209
+ ## Posting without leaving the page
210
+
211
+ The comment form under each post now sends in place and tells you whether your comment is live or waiting for approval.`
212
+ },
213
+ {
214
+ title: 'A colour palette in an afternoon',
215
+ slug: 'a-colour-palette-in-an-afternoon',
216
+ excerpt: 'Start from one colour you love, and let contrast rules do the rest.',
217
+ categories: ['design'], tags: ['colour', 'theming'],
218
+ featuredImage: IMG('meadow'), publishedAt: ago(52),
219
+ content: `You do not need a design degree to build a palette that works. You need one colour, and some discipline.
220
+
221
+ ## Start with one
222
+
223
+ Pick a primary colour you would be happy to see every day. It will be your links, your buttons and the accent on every card.
224
+
225
+ ## Derive the rest
226
+
227
+ A darker shade for hover, a pale tint for backgrounds, and a neutral grey family for text. The theme settings can generate these from the one colour.
228
+
229
+ ## Check contrast
230
+
231
+ Body text needs a contrast ratio of at least 4.5:1 against its background. Test it rather than trusting your eyes on a bright screen.`
232
+ }
233
+ ];
234
+ }
@@ -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
- import {getConfig, saveConfig} from '../../server/config.js';
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,24 +379,31 @@ 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;
383
+
384
+ // The schema marks title, slug, authorId and status required, so an
385
+ // empty title reaches createEntry and throws - a 500 where the
386
+ // categories and comments routes both answer 400. Say so here instead.
387
+ if (!title || typeof title !== 'string' || !title.trim()) {
388
+ return reply.code(400).send({ error: 'title is required' });
389
+ }
219
390
 
220
391
  const derivedSlug = slug
221
392
  ? String(slug).trim()
222
- : slugify(title ?? '');
393
+ : slugify(title);
223
394
 
395
+ const fields = postFields(body, user);
224
396
  const entry = await createEntry(POSTS_SLUG, {
225
- title: typeof title === 'string' ? title.trim() : '',
397
+ content: '',
398
+ excerpt: '',
399
+ featuredImage: null,
400
+ categories: [],
401
+ tags: [],
402
+ template: '',
403
+ ...fields,
404
+ title: title.trim(),
226
405
  slug: derivedSlug,
227
- content: typeof content === 'string' ? content : '',
228
- excerpt: typeof excerpt === 'string' ? excerpt.trim() : '',
229
- featuredImage: featuredImage ?? null,
230
- categories: Array.isArray(categories) ? categories : [],
231
- tags: Array.isArray(tags) ? tags : [],
232
- status: status ?? 'draft',
406
+ status: fields.status ?? 'draft',
233
407
  authorId: uid
234
408
  }, { createdBy: uid, source: 'admin' });
235
409
 
@@ -256,8 +430,7 @@ export default async function blogPlugin(fastify, options) {
256
430
  return reply.code(403).send({ error: 'Forbidden' });
257
431
  }
258
432
 
259
- const updates = request.body ?? {};
260
- const merged = { ...entry.data, ...updates };
433
+ const merged = { ...entry.data, ...postFields(request.body ?? {}, user) };
261
434
 
262
435
  const updated = await updateEntry(POSTS_SLUG, id, merged);
263
436
  return reply.send(toPost(updated));
@@ -401,10 +574,49 @@ export default async function blogPlugin(fastify, options) {
401
574
  return reply.send(comments);
402
575
  });
403
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
+
404
609
  /** POST /api/plugins/blog/comments - public, no authenticate */
405
610
  fastify.post('/comments', async (request, reply) => {
406
611
  const body = request.body ?? {};
407
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
+
408
620
  // Honeypot: non-empty `website` field = bot, silently discard
409
621
  if (body.website) {
410
622
  return reply.code(201).send({ ok: true });
@@ -417,7 +629,7 @@ export default async function blogPlugin(fastify, options) {
417
629
  if (!authorEmail) return reply.code(400).send({ error: 'authorEmail is required' });
418
630
  if (!commentBody) return reply.code(400).send({ error: 'body is required' });
419
631
 
420
- const commentStatus = settings.commentModeration !== false ? 'pending' : 'approved';
632
+ const commentStatus = live.commentModeration !== false ? 'pending' : 'approved';
421
633
 
422
634
  const entry = await createEntry(COMMENTS_SLUG, {
423
635
  postId: String(postId),
@@ -465,21 +677,42 @@ export default async function blogPlugin(fastify, options) {
465
677
  // Settings
466
678
  // -------------------------------------------------------------------------
467
679
 
680
+ // Plugin settings live in config/plugins.json under blog.settings, and the
681
+ // way to reach them is getPluginStates() / savePluginState(). These used to
682
+ // call getConfig() and saveConfig() from server/config.js, which take a FILE
683
+ // NAME: getConfig() with no argument read config/undefined.json and threw,
684
+ // so this endpoint answered 500 every time, and saveConfig(cfg) passed the
685
+ // whole config object as the name.
686
+
468
687
  /** GET /api/plugins/blog/settings */
469
688
  fastify.get('/settings', { preHandler: [authenticate] }, async (request, reply) => {
470
- const cfg = getConfig();
471
- const overrides = cfg.plugins?.blog?.settings ?? {};
472
- return reply.send({ ...defaultConfig, ...overrides });
689
+ const stored = getPluginStates()?.blog?.settings ?? {};
690
+ return reply.send({ ...defaultConfig, ...DISPLAY_DEFAULTS, ...stored });
473
691
  });
474
692
 
475
693
  /** PUT /api/plugins/blog/settings */
476
- fastify.put('/settings', { preHandler: [requireAdmin] }, async (request, reply) => {
694
+ // requireAdmin reads request.user, which only `authenticate` populates -
695
+ // on its own it answers 401 to everyone, admin included.
696
+ fastify.put('/settings', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
477
697
  const body = request.body ?? {};
478
- const cfg = getConfig();
479
- cfg.plugins ??= {};
480
- cfg.plugins.blog ??= {};
481
- cfg.plugins.blog.settings = body;
482
- await saveConfig(cfg);
483
- return reply.send({ ok: true });
698
+
699
+ // Whitelist rather than merge: every key here is read back by the
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 ?? {};
704
+ const merged = {
705
+ ...defaultConfig,
706
+ ...stored,
707
+ ...(body.basePath !== undefined && { basePath: String(body.basePath) }),
708
+ ...(body.postsPerPage !== undefined && { postsPerPage: Number(body.postsPerPage) || defaultConfig.postsPerPage }),
709
+ ...(body.commentsEnabled !== undefined && { commentsEnabled: Boolean(body.commentsEnabled) }),
710
+ ...(body.commentModeration !== undefined && { commentModeration: Boolean(body.commentModeration) }),
711
+ ...(body.feedLength !== undefined && { feedLength: Number(body.feedLength) || defaultConfig.feedLength }),
712
+ ...pickDisplaySettings(body)
713
+ };
714
+
715
+ savePluginState('blog', { settings: merged });
716
+ return reply.send({ ok: true, settings: merged });
484
717
  });
485
718
  }