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
@@ -1,12 +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": {
11
+ "css": ["admin/css/index.css"],
10
12
  "sidebar": [
11
13
  {
12
14
  "id": "blog",
@@ -14,83 +16,29 @@
14
16
  "icon": "file-text",
15
17
  "url": "#/plugins/blog",
16
18
  "section": "#/plugins/blog",
19
+ "countUrl": "/api/plugins/blog/comments/pending-count",
17
20
  "children": [
18
- {
19
- "id": "blog-posts",
20
- "text": "Posts",
21
- "url": "#/plugins/blog/posts"
22
- },
23
- {
24
- "id": "blog-categories",
25
- "text": "Categories",
26
- "url": "#/plugins/blog/categories"
27
- },
28
- {
29
- "id": "blog-comments",
30
- "text": "Comments",
31
- "url": "#/plugins/blog/comments"
32
- },
33
- {
34
- "id": "blog-settings",
35
- "text": "Settings",
36
- "url": "#/plugins/blog/settings"
37
- }
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" }
38
24
  ]
39
25
  }
40
26
  ],
41
27
  "routes": [
42
- {
43
- "path": "/plugins/blog/posts",
44
- "view": "plugin-blog-posts",
45
- "title": "Blog Posts - Domma CMS"
46
- },
47
- {
48
- "path": "/plugins/blog/posts/new",
49
- "view": "plugin-blog-post-editor",
50
- "title": "New Post - Domma CMS"
51
- },
52
- {
53
- "path": "/plugins/blog/posts/:id",
54
- "view": "plugin-blog-post-editor",
55
- "title": "Edit Post - Domma CMS"
56
- },
57
- {
58
- "path": "/plugins/blog/categories",
59
- "view": "plugin-blog-categories",
60
- "title": "Blog Categories - Domma CMS"
61
- },
62
- {
63
- "path": "/plugins/blog/comments",
64
- "view": "plugin-blog-comments",
65
- "title": "Blog Comments - Domma CMS"
66
- },
67
- {
68
- "path": "/plugins/blog/settings",
69
- "view": "plugin-blog-settings",
70
- "title": "Blog Settings - Domma CMS"
71
- }
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" }
72
35
  ],
73
36
  "views": {
74
- "plugin-blog-posts": {
75
- "entry": "blog/admin/views/blog.js?v=da5c1a46",
76
- "exportName": "blogView"
77
- },
78
- "plugin-blog-post-editor": {
79
- "entry": "blog/admin/views/post-editor.js?v=d4d7a514",
80
- "exportName": "postEditorView"
81
- },
82
- "plugin-blog-categories": {
83
- "entry": "blog/admin/views/categories.js?v=7e3946ca",
84
- "exportName": "categoriesView"
85
- },
86
- "plugin-blog-comments": {
87
- "entry": "blog/admin/views/comments.js?v=ee2146e9",
88
- "exportName": "commentsView"
89
- },
90
- "plugin-blog-settings": {
91
- "entry": "blog/admin/views/settings.js?v=3fab4235",
92
- "exportName": "settingsView"
93
- }
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" }
94
42
  }
95
43
  }
96
44
  }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Blog Plugin - Public Routes
3
+ * SSR public-facing routes for the blog. Mounted at the Fastify root.
4
+ *
5
+ * Posts and listing cards render through BLOCKS (see lib/layouts.js), and the
6
+ * page works fully without JavaScript. public/blog.js then takes the listing
7
+ * over - search, category pills, sort, display switching and a right-click
8
+ * menu - and adds reading progress, contents tracking, sharing and in-page
9
+ * comments to a post.
10
+ */
11
+ import path from 'path';
12
+ import {fileURLToPath} from 'url';
13
+ import {renderBlogPage} from '../../server/services/renderer.js';
14
+ import {byNewest, escapeHtml, pickDisplay, safeImageUrl} from './lib/layouts.js';
15
+ import {catalogue, renderCards} from './lib/render.js';
16
+ import {authorNamesFor, isVisible, liveSettings, loadEntries, renderPostPage, resolveAuthorName} from './lib/page.js';
17
+
18
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
19
+ const TEMPLATES_DIR = path.join(__dirname, 'templates');
20
+
21
+ /** Bumped with the plugin, so a release is never served a stale script. */
22
+ const ASSET_V = '1.4.4';
23
+
24
+ /**
25
+ * Above this many posts a listing pages on the server instead: the browser
26
+ * filters what it was sent, and sending thousands of cards is not a feature.
27
+ */
28
+ const INTERACTIVE_CAP = 300;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function buildPagination(page, totalPages, basePath, query = '') {
35
+ if (totalPages <= 1) return '';
36
+ const parts = [];
37
+ if (page > 1) {
38
+ const prevHref = page === 2 ? basePath : `${basePath}/page/${page - 1}`;
39
+ parts.push(`<a href="${prevHref}${query}" class="btn btn-sm btn-secondary">&laquo; Newer</a>`);
40
+ }
41
+ parts.push(`<span class="blog-page-of">Page ${page} of ${totalPages}</span>`);
42
+ if (page < totalPages) {
43
+ parts.push(`<a href="${basePath}/page/${page + 1}${query}" class="btn btn-sm btn-secondary">Older &raquo;</a>`);
44
+ }
45
+ return `<nav class="blog-pagination" aria-label="Pages">${parts.join('')}</nav>`;
46
+ }
47
+
48
+ /** JSON inside a <script> element, safe against `</script>` in any value. */
49
+ function scriptJson(value) {
50
+ return JSON.stringify(value).replace(/</g, '\\u003c');
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Plugin
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export default async function blogPublicPlugin(fastify, options) {
58
+ const {settings, config} = options;
59
+ const base = (settings?.basePath ?? '/blog').replace(/\/$/, '');
60
+
61
+ /**
62
+ * A listing: the index, a page of it, or a category / tag / author archive.
63
+ *
64
+ * @param {object} request
65
+ * @param {object} reply
66
+ * @param {object[]} posts - candidates; drafts are dropped here
67
+ * @param {object} opts - {title, intro, page, pageBase, filter}
68
+ */
69
+ async function renderArchive(request, reply, posts, {title, intro = '', page = 1, pageBase = base, filter = null}) {
70
+ const s = liveSettings(settings);
71
+ const perPage = Math.max(1, Number(s.postsPerPage) || 10);
72
+ const {listDisplays} = await catalogue();
73
+ const display = pickDisplay(String(request.query?.display ?? ''), s, listDisplays);
74
+
75
+ const visible = posts.filter(isVisible).sort(byNewest);
76
+ const interactive = Boolean(s.interactive) && visible.length <= INTERACTIVE_CAP;
77
+ const totalPages = Math.max(1, Math.ceil(visible.length / perPage));
78
+ const safePage = Math.min(Math.max(1, page), totalPages);
79
+ // Interactive listings send every post and page in the browser; the
80
+ // script shows the first page at once, so nothing jumps.
81
+ const shown = interactive ? visible : visible.slice((safePage - 1) * perPage, safePage * perPage);
82
+
83
+ const categories = await loadEntries('blog-categories');
84
+ const catMap = Object.fromEntries(categories.map(c => [c.id, {name: c.name, slug: c.slug}]));
85
+ const authorNames = await authorNamesFor(shown);
86
+ const {items, style} = await renderCards(shown, display, {base, catMap, authorNames});
87
+
88
+ const requested = request.query?.display ? `?display=${encodeURIComponent(display.id)}` : '';
89
+ const switcher = s.displaySwitcher && listDisplays.length > 1
90
+ ? `<div class="blog-displays" role="group" aria-label="Display">${listDisplays.map(d =>
91
+ `<a href="?display=${encodeURIComponent(d.id)}" data-blog-display="${escapeHtml(d.id)}" title="${escapeHtml(d.description)}"${d.id === display.id ? ' aria-current="true"' : ''}>${escapeHtml(d.label)}</a>`
92
+ ).join('')}</div>`
93
+ : '';
94
+
95
+ const cfg = {
96
+ base, perPage, interactive, filter,
97
+ display: display.id,
98
+ displays: listDisplays.map(d => ({id: d.id, arrange: d.arrange, lead: Boolean(d.lead)})),
99
+ categories: categories.map(c => ({slug: c.slug, name: c.name}))
100
+ };
101
+
102
+ const archive = `${style}
103
+ <section class="blog-archive" data-blog-archive>
104
+ <script type="application/json" data-blog-config>${scriptJson(cfg)}</script>
105
+ <div class="blog-archive-bar">${switcher}</div>
106
+ <div class="blog-items blog-items--${escapeHtml(display.arrange)}${display.lead ? ' blog-items--lead' : ''}" data-display="${escapeHtml(display.id)}">
107
+ ${items || ''}
108
+ </div>
109
+ <p class="blog-empty"${shown.length ? ' hidden' : ''}>No posts here yet.</p>
110
+ </section>`;
111
+
112
+ const html = await renderBlogPage(
113
+ path.join(TEMPLATES_DIR, 'index.html'),
114
+ {
115
+ archiveTitle: escapeHtml(title),
116
+ archiveIntro: intro ? `<p class="blog-archive-intro">${escapeHtml(intro)}</p>` : '',
117
+ archive,
118
+ pagination: interactive ? '' : buildPagination(safePage, totalPages, pageBase, requested),
119
+ assetV: ASSET_V
120
+ },
121
+ {title, urlPath: request.url.split('?')[0]}
122
+ );
123
+ return reply.type('text/html').send(html);
124
+ }
125
+
126
+ // 1. RSS feed
127
+ fastify.get(`${base}/feed.xml`, async (request, reply) => {
128
+ const s = liveSettings(settings);
129
+ const allPosts = await loadEntries('blog-posts');
130
+ const posts = allPosts.filter(isVisible).sort(byNewest).slice(0, s.feedLength ?? 20);
131
+
132
+ const siteTitle = config?.site?.title ?? 'Blog';
133
+ const siteUrl = (config?.site?.url ?? '').replace(/\/$/, '');
134
+
135
+ const items = posts.map((post) => ` <item>
136
+ <title>${escapeHtml(post.title)}</title>
137
+ <link>${siteUrl}${base}/${escapeHtml(post.slug)}</link>
138
+ <description>${escapeHtml(post.excerpt || '')}</description>
139
+ <pubDate>${new Date(post.publishedAt).toUTCString()}</pubDate>
140
+ <guid>${siteUrl}${base}/${escapeHtml(post.slug)}</guid>
141
+ </item>`).join('\n');
142
+
143
+ return reply.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
144
+ <rss version="2.0">
145
+ <channel>
146
+ <title>${escapeHtml(siteTitle)}</title>
147
+ <link>${siteUrl}${base}</link>
148
+ <description>Blog feed</description>
149
+ ${items}
150
+ </channel>
151
+ </rss>`);
152
+ });
153
+
154
+ // 2. Paginated index
155
+ fastify.get(`${base}/page/:n`, async (request, reply) => {
156
+ const page = parseInt(request.params.n, 10) || 1;
157
+ return renderArchive(request, reply, await loadEntries('blog-posts'), {title: 'Blog', page});
158
+ });
159
+
160
+ // 3. Category archive
161
+ fastify.get(`${base}/category/:slug`, async (request, reply) => {
162
+ const {slug} = request.params;
163
+ const [allPosts, allCategories] = await Promise.all([loadEntries('blog-posts'), loadEntries('blog-categories')]);
164
+ const category = allCategories.find((c) => c.slug === slug);
165
+ if (!category) return reply.code(404).send('Category not found');
166
+ const posts = allPosts.filter((p) => Array.isArray(p.categories) && p.categories.includes(category.id));
167
+ return renderArchive(request, reply, posts, {
168
+ title: category.name, intro: category.description || '', filter: {type: 'category', value: category.slug}
169
+ });
170
+ });
171
+
172
+ // 4. Tag archive
173
+ fastify.get(`${base}/tag/:tag`, async (request, reply) => {
174
+ const {tag} = request.params;
175
+ const posts = (await loadEntries('blog-posts')).filter((p) => Array.isArray(p.tags) && p.tags.includes(tag));
176
+ return renderArchive(request, reply, posts, {title: `#${tag}`, filter: {type: 'tag', value: tag}});
177
+ });
178
+
179
+ // 5. Author archive
180
+ fastify.get(`${base}/author/:id`, async (request, reply) => {
181
+ const {id} = request.params;
182
+ const posts = (await loadEntries('blog-posts')).filter((p) => p.authorId === id);
183
+ const authorName = await resolveAuthorName(id);
184
+ return renderArchive(request, reply, posts, {title: `Posts by ${authorName}`, filter: {type: 'author', value: id}});
185
+ });
186
+
187
+ // 6. Blog index
188
+ fastify.get(base, async (request, reply) => {
189
+ return renderArchive(request, reply, await loadEntries('blog-posts'), {title: 'Blog'});
190
+ });
191
+
192
+ // 7. Individual post - registered LAST so the slug match doesn't shadow archives
193
+ fastify.get(`${base}/:slug`, async (request, reply) => {
194
+ const {slug} = request.params;
195
+ const allPosts = await loadEntries('blog-posts');
196
+ const post = allPosts.find((p) => p.slug === slug);
197
+ if (!post || !isVisible(post)) return reply.code(404).send('Not found');
198
+
199
+ const siteUrl = (config?.site?.url || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
200
+ const page = await renderPostPage(post, allPosts, liveSettings(settings), {base, siteUrl});
201
+
202
+ const html = await renderBlogPage(
203
+ path.join(TEMPLATES_DIR, 'post.html'),
204
+ {post: page.html, assetV: ASSET_V},
205
+ {
206
+ title: post.seo?.title || post.title,
207
+ description: post.seo?.description || post.excerpt || '',
208
+ ogImage: safeImageUrl(post.featuredImage),
209
+ ogType: 'article',
210
+ usedComponents: page.usedComponents,
211
+ urlPath: `${base}/${slug}`
212
+ }
213
+ );
214
+ return reply.type('text/html').send(html);
215
+ });
216
+
217
+ }
@@ -0,0 +1,177 @@
1
+ /*
2
+ * Blog - what every layout shares: the listing's frame and toolbar, chips,
3
+ * post typography, contents list, sharing, comments and the right-click menu.
4
+ * Each layout's own look lives in its block (content/blocks/blog-*.css), so a
5
+ * site restyles a layout in the Blocks tool without touching this file.
6
+ */
7
+
8
+ .blog-page { padding-top: 2rem; padding-bottom: 3rem; }
9
+
10
+ /*
11
+ * The site theme colours and underlines every link in .page-body, with
12
+ * !important and a selector as specific as (0,8,4) (public/css/site.css). A
13
+ * blog's links are its interface - titles, chips, the display switcher - so they
14
+ * opt out here, by id to outrank it. Links written in a post keep the theme's
15
+ * look. A block's CSS sets link colour on the link's parent (it inherits).
16
+ * The reset sits in :where() so it weighs only its id (1,0,0) - still above the
17
+ * theme, and below every exception here. Without it, `:not(.blog-content a)`
18
+ * lifted the reset over the exceptions and the active display button went
19
+ * dark-on-dark.
20
+ */
21
+ #dm-blog :where(a[href]:not(.blog-content a)) { color: inherit !important; text-decoration: none !important; }
22
+ #dm-blog :where(a[href]:not(.blog-content a)):hover { color: var(--dm-primary, #3b82f6) !important; }
23
+ #dm-blog a.blog-chip { color: var(--dm-primary, #3b82f6) !important; }
24
+ #dm-blog .blog-displays a[aria-current="true"] { color: #fff !important; }
25
+ #dm-blog a.blog-tag, #dm-blog .blog-toc a { color: var(--dm-text-muted) !important; }
26
+ #dm-blog a.blog-tag:hover, #dm-blog .blog-toc a:hover { color: var(--dm-text) !important; }
27
+ #dm-blog .blog-toc a.is-active { color: var(--dm-primary, #3b82f6) !important; }
28
+
29
+ /* ── Listing ──────────────────────────────────────────────────────────── */
30
+ .blog-archive-head { margin-bottom: 1.25rem; }
31
+ .blog-archive-head h1 { margin: 0 0 .35rem; font-size: clamp(2rem, 4vw, 2.6rem); letter-spacing: -.015em; }
32
+ .blog-archive-intro { margin: 0; color: var(--dm-text-muted); font-size: 1.1rem; max-width: 42rem; }
33
+
34
+ .blog-archive-bar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: 1rem; }
35
+ .blog-archive-bar:empty { display: none; }
36
+
37
+ .blog-search { position: relative; flex: 1 1 16rem; max-width: 24rem; }
38
+ .blog-search .blog-search-input { width: 100%; padding: .5rem .75rem .5rem 2.1rem; border-radius: 999px;
39
+ border: 1px solid var(--dm-border, rgba(127,127,127,.3)); background: var(--dm-surface, transparent); color: inherit; font: inherit; }
40
+ .blog-search .blog-search-input:focus { outline: 2px solid color-mix(in srgb, var(--dm-primary, #3b82f6) 45%, transparent); outline-offset: 1px; }
41
+ .blog-search svg { position: absolute; left: .75rem; top: 50%; transform: translateY(-50%); opacity: .55; pointer-events: none; }
42
+
43
+ .blog-sort { padding: .45rem .6rem; border-radius: 999px; border: 1px solid var(--dm-border, rgba(127,127,127,.3));
44
+ background: var(--dm-surface, transparent); color: inherit; font: inherit; font-size: .9rem; }
45
+
46
+ .blog-displays { display: inline-flex; margin-left: auto; padding: 3px; border-radius: 999px;
47
+ border: 1px solid var(--dm-border, rgba(127,127,127,.3)); }
48
+ .blog-displays a { padding: .3rem .8rem; border-radius: 999px; font-size: .85rem; text-decoration: none; color: var(--dm-text-muted); }
49
+ .blog-displays a:hover { color: var(--dm-text); }
50
+ .blog-displays a[aria-current="true"] { background: var(--dm-primary, #3b82f6); color: #fff; }
51
+
52
+ .blog-pills { display: flex; gap: .4rem; flex-wrap: wrap; margin: 0 0 1rem; }
53
+ .blog-pill { display: inline-flex; align-items: center; gap: .4rem; padding: .3rem .8rem; border-radius: 999px; cursor: pointer;
54
+ border: 1px solid var(--dm-border, rgba(127,127,127,.3)); background: none; color: inherit; font: inherit; font-size: .88rem; }
55
+ .blog-pill:hover { border-color: var(--dm-primary, #3b82f6); }
56
+ .blog-pill.is-active { background: var(--dm-primary, #3b82f6); border-color: var(--dm-primary, #3b82f6); color: #fff; }
57
+ .blog-pill .n { font-size: .75rem; opacity: .7; font-variant-numeric: tabular-nums; }
58
+ .blog-pill[disabled] { opacity: .4; cursor: default; }
59
+
60
+ .blog-active { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin: -.25rem 0 1rem; font-size: .88rem; color: var(--dm-text-muted); }
61
+ .blog-active:empty { display: none; }
62
+ .blog-active .blog-filter-chip { display: inline-flex; align-items: center; gap: .35rem; padding: .2rem .35rem .2rem .7rem; border-radius: 999px;
63
+ background: color-mix(in srgb, var(--dm-primary, #3b82f6) 14%, transparent); color: var(--dm-text); }
64
+ .blog-active .blog-filter-chip button { border: 0; background: none; color: inherit; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 .25rem; border-radius: 50%; }
65
+ .blog-active .blog-filter-chip button:hover { background: rgba(127,127,127,.2); }
66
+ .blog-active .blog-clear { border: 0; background: none; color: var(--dm-primary); cursor: pointer; font: inherit; text-decoration: underline; padding: 0; }
67
+
68
+ .blog-items--grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr)); gap: 1.5rem; }
69
+ .blog-items--stack { display: flex; flex-direction: column; }
70
+ .blog-items .blog-item[hidden] { display: none !important; }
71
+ .blog-items.is-swapping { opacity: .35; transition: opacity .15s; }
72
+
73
+ .blog-empty { padding: 3rem 1rem; text-align: center; color: var(--dm-text-muted); }
74
+ .blog-more { display: flex; flex-direction: column; align-items: center; gap: .5rem; margin-top: 2rem; }
75
+ .blog-more-count { font-size: .85rem; color: var(--dm-text-muted); }
76
+ .blog-pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-top: 2rem; }
77
+ .blog-page-of { font-size: .9rem; color: var(--dm-text-muted); }
78
+
79
+ /* Right-click affordance on a listing that answers it. */
80
+ .blog-archive.is-live .blog-item { cursor: context-menu; }
81
+ .blog-archive.is-live .blog-item a { cursor: pointer; }
82
+ .blog-item.is-flash { animation: blog-flash .9s ease; }
83
+ @keyframes blog-flash { 0% { box-shadow: 0 0 0 3px var(--dm-primary, #3b82f6); } 100% { box-shadow: 0 0 0 3px transparent; } }
84
+
85
+ /* ── Chips and tags ───────────────────────────────────────────────────── */
86
+ .blog-chip { display: inline-block; padding: .12rem .6rem; border-radius: 999px; font-size: .72rem; font-weight: 600;
87
+ letter-spacing: .03em; text-transform: uppercase; text-decoration: none;
88
+ color: var(--dm-primary, #3b82f6); background: color-mix(in srgb, var(--dm-primary, #3b82f6) 12%, transparent);
89
+ border: 1px solid transparent; }
90
+ .blog-chip:hover { border-color: currentColor; }
91
+ .blog-tag { font-size: .85rem; color: var(--dm-text-muted); text-decoration: none; }
92
+ .blog-tag:hover { color: var(--dm-primary); }
93
+
94
+ /* ── A post ───────────────────────────────────────────────────────────── */
95
+ .blog-progress { position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 1100; pointer-events: none; }
96
+ .blog-progress span { display: block; height: 100%; width: 100%; transform-origin: 0 50%; transform: scaleX(0);
97
+ background: var(--dm-primary, #3b82f6); transition: transform .08s linear; }
98
+
99
+ .blog-content { font-size: 1.075rem; line-height: 1.75; }
100
+ .blog-content > * + * { margin-top: 1.1em; }
101
+ .blog-content h2 { font-size: 1.6rem; margin-top: 2em; line-height: 1.25; scroll-margin-top: calc(var(--dm-navbar-height, 64px) + 1rem); }
102
+ .blog-content h3 { font-size: 1.25rem; margin-top: 1.6em; scroll-margin-top: calc(var(--dm-navbar-height, 64px) + 1rem); }
103
+ .blog-content img { max-width: 100%; height: auto; border-radius: var(--dm-radius-md, 8px); }
104
+ .blog-content blockquote { margin: 1.75em 0; padding: .25em 0 .25em 1.25em; border-left: 3px solid var(--dm-primary, #3b82f6);
105
+ font-size: 1.2em; font-style: italic; color: var(--dm-text-muted); }
106
+ .blog-content pre { padding: 1rem; border-radius: var(--dm-radius-md, 8px); overflow-x: auto; background: rgba(127,127,127,.1); font-size: .9em; }
107
+ .blog-content :not(pre) > code { padding: .1em .35em; border-radius: 4px; background: rgba(127,127,127,.14); font-size: .9em; }
108
+
109
+ .blog-toc { font-size: .9rem; }
110
+ .blog-toc-title { margin: 0 0 .5rem; font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--dm-text-muted); }
111
+ .blog-toc ol { list-style: none; margin: 0; padding: 0; border-left: 2px solid var(--dm-border, rgba(127,127,127,.25)); }
112
+ .blog-toc li a { display: block; padding: .3rem 0 .3rem .9rem; margin-left: -2px; border-left: 2px solid transparent;
113
+ color: var(--dm-text-muted); text-decoration: none; line-height: 1.35; }
114
+ .blog-toc li.blog-toc-l3 a { padding-left: 1.7rem; font-size: .85rem; }
115
+ .blog-toc li a:hover { color: var(--dm-text); }
116
+ .blog-toc li a.is-active { color: var(--dm-primary, #3b82f6); border-left-color: var(--dm-primary, #3b82f6); font-weight: 600; }
117
+
118
+ .blog-share { display: flex; gap: .4rem; flex-wrap: wrap; }
119
+ .blog-share-btn { display: inline-flex; align-items: center; gap: .35rem; padding: .35rem .8rem; border-radius: 999px; cursor: pointer;
120
+ border: 1px solid var(--dm-border, rgba(127,127,127,.3)); background: none; color: inherit; font: inherit; font-size: .85rem; text-decoration: none; }
121
+ .blog-share-btn:hover { border-color: var(--dm-primary, #3b82f6); color: var(--dm-primary, #3b82f6); }
122
+
123
+ .blog-prevnext { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 2.5rem 0 0; }
124
+ .blog-prevnext a { display: flex; flex-direction: column; gap: .25rem; padding: 1rem 1.1rem; border-radius: var(--dm-radius-lg, 12px);
125
+ border: 1px solid var(--dm-border, rgba(127,127,127,.25)); text-decoration: none; color: inherit; }
126
+ .blog-prevnext a:hover { border-color: var(--dm-primary, #3b82f6); }
127
+ .blog-prevnext a span { font-size: .78rem; color: var(--dm-text-muted); text-transform: uppercase; letter-spacing: .05em; }
128
+ .blog-prevnext .is-newer { text-align: right; grid-column: 2; }
129
+ @media (max-width: 600px) { .blog-prevnext { grid-template-columns: 1fr; } .blog-prevnext .is-newer { grid-column: 1; } }
130
+
131
+ .blog-section-title { font-size: 1.35rem; margin: 0 0 1.1rem; display: flex; align-items: center; gap: .5rem; }
132
+ .blog-related { margin-top: 3.5rem; padding-top: 2rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
133
+ .blog-count { font-size: .8rem; padding: .05rem .5rem; border-radius: 999px; background: rgba(127,127,127,.16); font-weight: 600; }
134
+
135
+ /* ── Comments ─────────────────────────────────────────────────────────── */
136
+ .blog-comments { margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); }
137
+ .blog-comment-list { list-style: none; margin: 0 0 2rem; padding: 0; display: flex; flex-direction: column; gap: 1.1rem; }
138
+ .blog-comment-list:empty { display: none; }
139
+ .blog-comment { padding: .9rem 1rem; border-radius: var(--dm-radius-md, 8px); background: rgba(127,127,127,.07); }
140
+ .blog-comment.is-new { outline: 2px solid color-mix(in srgb, var(--dm-success, #16a34a) 55%, transparent); }
141
+ .blog-comment-head { margin: 0 0 .35rem; font-size: .9rem; }
142
+ .blog-comment-head time { color: var(--dm-text-muted); font-size: .82rem; margin-left: .35rem; }
143
+ .blog-comment-body { margin: 0; white-space: pre-line; }
144
+ .blog-comment-form { display: flex; flex-direction: column; gap: .8rem; }
145
+ .blog-comment-form h3 { margin: 0; font-size: 1.1rem; }
146
+ .blog-comment-form label { display: flex; flex-direction: column; gap: .3rem; font-size: .9rem; font-weight: 600; }
147
+ .blog-comment-form label small { font-weight: 400; color: var(--dm-text-muted); }
148
+ .blog-comment-row { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
149
+ @media (max-width: 600px) { .blog-comment-row { grid-template-columns: 1fr; } }
150
+ .blog-comment-actions { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
151
+ .blog-comment-status { font-size: .9rem; }
152
+ .blog-comment-status.is-ok { color: var(--dm-success, #16a34a); }
153
+ .blog-comment-status.is-error { color: var(--dm-danger, #dc2626); }
154
+ .blog-hp { position: absolute !important; left: -9999px !important; width: 1px; height: 1px; overflow: hidden; }
155
+
156
+ /* ── The right-click menu ─────────────────────────────────────────────── */
157
+ .blog-menu { position: fixed; z-index: 10000; min-width: 220px; max-width: 300px; padding: .3rem;
158
+ background: var(--dm-surface, #fff); color: var(--dm-text, #111); border: 1px solid var(--dm-border, rgba(127,127,127,.3));
159
+ border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.22); font-size: .88rem; }
160
+ .blog-menu[hidden] { display: none; }
161
+ .blog-menu-head { padding: .45rem .65rem .35rem; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase;
162
+ color: var(--dm-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
163
+ .blog-menu button { display: flex; align-items: center; gap: .55rem; width: 100%; padding: .45rem .65rem; border: 0; border-radius: 6px;
164
+ background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
165
+ .blog-menu button:hover, .blog-menu button:focus-visible { background: color-mix(in srgb, var(--dm-primary, #3b82f6) 12%, transparent); outline: none; }
166
+ .blog-menu button .k { margin-left: auto; font-size: .78rem; color: var(--dm-text-muted); }
167
+ .blog-menu hr { border: 0; border-top: 1px solid var(--dm-border, rgba(127,127,127,.25)); margin: .3rem .2rem; }
168
+ .blog-toast { position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%); z-index: 10001; padding: .55rem 1rem;
169
+ border-radius: 999px; background: var(--dm-text, #111); color: var(--dm-background, #fff); font-size: .88rem; box-shadow: 0 6px 20px rgba(0,0,0,.25); }
170
+
171
+ @media (prefers-reduced-motion: reduce) {
172
+ .blog-item, .blog-item * { transition: none !important; animation: none !important; }
173
+ }
174
+
175
+ /* A Feature post's hero starts where the theme's content does - no page padding
176
+ above it. (It cannot reach the navbar: the theme's wrapper clips overflow.) */
177
+ .blog-page:has(.pf-hero) { padding-top: 0; }