domma-cms 0.54.2 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CLAUDE.md +151 -77
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/menu-editor.html +19 -25
  19. package/admin/js/templates/plugin-code.html +1 -1
  20. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  21. package/admin/js/templates/settings.html +27 -97
  22. package/admin/js/templates/theme.html +173 -0
  23. package/admin/js/views/context-menu-editor.js +55 -0
  24. package/admin/js/views/context-menus.js +5 -0
  25. package/admin/js/views/form-editor.js +7 -7
  26. package/admin/js/views/index.js +1 -1
  27. package/admin/js/views/menu-editor.js +13 -13
  28. package/admin/js/views/plugin-marketplace.js +1 -1
  29. package/admin/js/views/plugins.js +25 -23
  30. package/admin/js/views/search.js +1 -0
  31. package/admin/js/views/settings.js +3 -3
  32. package/admin/js/views/theme.js +1 -0
  33. package/bin/cli.js +11 -2
  34. package/bin/lib/smtp-defaults.js +53 -0
  35. package/bin/update.js +13 -2
  36. package/config/menus/admin-sidebar.json +129 -23
  37. package/config/plugins.json +5 -5
  38. package/config/search.json +13 -0
  39. package/config/theme.json +18 -0
  40. package/package.json +12 -4
  41. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  42. package/plugins/_lib/admin/mail/contacts.js +301 -0
  43. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  44. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  45. package/plugins/_lib/admin/mail/identity.js +480 -0
  46. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  47. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  48. package/plugins/_lib/admin/mail/mail.css +1 -0
  49. package/plugins/_lib/admin/mail/mail.html +71 -0
  50. package/plugins/_lib/admin/mail/panes.js +253 -0
  51. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  52. package/plugins/_lib/admin/mail/resizable.js +26 -0
  53. package/plugins/_lib/admin/mail/rules.js +343 -0
  54. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  55. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  56. package/plugins/_lib/admin/mail/templates.js +238 -0
  57. package/plugins/_lib/admin/mail/threads.js +200 -0
  58. package/plugins/_lib/admin/mail/vacation.js +269 -0
  59. package/plugins/_lib/admin/ui/help.css +1 -0
  60. package/plugins/_lib/admin/ui/help.js +174 -0
  61. package/plugins/_lib/admin/ui/resizable.js +151 -0
  62. package/plugins/_lib/dataStore.js +117 -0
  63. package/plugins/_lib/mail/accounts.js +919 -0
  64. package/plugins/_lib/mail/bodyTokens.js +101 -0
  65. package/plugins/_lib/mail/compose.js +256 -0
  66. package/plugins/_lib/mail/defaults.js +57 -0
  67. package/plugins/_lib/mail/diagnostics.js +154 -0
  68. package/plugins/_lib/mail/envelope.js +161 -0
  69. package/plugins/_lib/mail/folders.js +192 -0
  70. package/plugins/_lib/mail/handoff.js +274 -0
  71. package/plugins/_lib/mail/imapPool.js +293 -0
  72. package/plugins/_lib/mail/mbox.js +74 -0
  73. package/plugins/_lib/mail/pollSchedule.js +79 -0
  74. package/plugins/_lib/mail/poller.js +135 -0
  75. package/plugins/_lib/mail/priority.js +138 -0
  76. package/plugins/_lib/mail/readRoutes.js +680 -0
  77. package/plugins/_lib/mail/render.js +291 -0
  78. package/plugins/_lib/mail/ruleRunner.js +152 -0
  79. package/plugins/_lib/mail/scheduler.js +254 -0
  80. package/plugins/_lib/mail/secretbox.js +229 -0
  81. package/plugins/_lib/mail/send.js +396 -0
  82. package/plugins/_lib/mail/store.js +1002 -0
  83. package/plugins/_lib/mail/sync.js +292 -0
  84. package/plugins/_lib/mail/syncPlan.js +126 -0
  85. package/plugins/_lib/mail/syncSelection.js +82 -0
  86. package/plugins/_lib/mail/unsubscribe.js +183 -0
  87. package/plugins/_lib/mail/vacationRunner.js +114 -0
  88. package/plugins/_lib/mail/write.js +473 -0
  89. package/plugins/_lib/schemaSync.js +83 -0
  90. package/plugins/_template/admin/css/index.css +0 -0
  91. package/plugins/_template/admin/templates/index.html +4 -4
  92. package/plugins/_template/admin/views/index.js +7 -0
  93. package/plugins/analytics/admin/css/index.css +1 -0
  94. package/plugins/analytics/admin/templates/analytics.html +22 -13
  95. package/plugins/analytics/plugin.json +3 -0
  96. package/plugins/blog/admin/css/index.css +1 -0
  97. package/plugins/blog/admin/templates/blog.html +30 -18
  98. package/plugins/blog/admin/templates/categories.html +2 -2
  99. package/plugins/blog/admin/templates/comments.html +2 -2
  100. package/plugins/blog/admin/templates/post-editor.html +34 -34
  101. package/plugins/blog/admin/templates/settings.html +6 -3
  102. package/plugins/blog/admin/views/blog.js +8 -5
  103. package/plugins/blog/admin/views/categories.js +5 -10
  104. package/plugins/blog/admin/views/comments.js +5 -5
  105. package/plugins/blog/admin/views/post-editor.js +39 -20
  106. package/plugins/blog/admin/views/settings.js +52 -50
  107. package/plugins/blog/collections/categories/schema.json +7 -6
  108. package/plugins/blog/collections/comments/schema.json +11 -10
  109. package/plugins/blog/collections/posts/schema.json +14 -13
  110. package/plugins/blog/plugin.js +36 -13
  111. package/plugins/blog/plugin.json +13 -5
  112. package/plugins/blog/plugin.public.js +312 -0
  113. package/plugins/contacts/admin/css/index.css +1 -0
  114. package/plugins/contacts/admin/templates/contacts.html +128 -0
  115. package/plugins/contacts/admin/views/contacts.js +237 -4
  116. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  117. package/plugins/contacts/plugin.js +214 -27
  118. package/plugins/contacts/plugin.json +4 -1
  119. package/plugins/invoice/admin/css/index.css +1 -0
  120. package/plugins/invoice/admin/templates/editor.html +140 -49
  121. package/plugins/invoice/admin/templates/index.html +153 -23
  122. package/plugins/invoice/admin/templates/issuers.html +2 -5
  123. package/plugins/invoice/admin/templates/receivers.html +2 -5
  124. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  125. package/plugins/invoice/admin/views/editor.js +366 -199
  126. package/plugins/invoice/admin/views/export.js +199 -0
  127. package/plugins/invoice/admin/views/help-content.js +61 -0
  128. package/plugins/invoice/admin/views/index.js +582 -94
  129. package/plugins/invoice/admin/views/issuers.js +24 -17
  130. package/plugins/invoice/admin/views/media.js +172 -0
  131. package/plugins/invoice/admin/views/party-view.js +305 -67
  132. package/plugins/invoice/admin/views/payments.js +127 -0
  133. package/plugins/invoice/admin/views/print.js +130 -0
  134. package/plugins/invoice/admin/views/receivers.js +49 -16
  135. package/plugins/invoice/admin/views/send.js +212 -0
  136. package/plugins/invoice/admin/views/settings.js +594 -0
  137. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  138. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  139. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  140. package/plugins/invoice/collections/invoices/schema.json +19 -13
  141. package/plugins/invoice/config.js +27 -6
  142. package/plugins/invoice/pdf.js +164 -0
  143. package/plugins/invoice/plugin.js +1217 -44
  144. package/plugins/invoice/plugin.json +10 -9
  145. package/plugins/invoice/templates/_base.css +1 -0
  146. package/plugins/invoice/templates/classic-nologo.html +100 -0
  147. package/plugins/invoice/templates/classic.html +91 -0
  148. package/plugins/invoice/templates/invoice-print.html +24 -0
  149. package/plugins/invoice/templates/minimal.html +99 -0
  150. package/plugins/invoice/templates/modern-nologo.html +114 -0
  151. package/plugins/invoice/templates/modern.html +113 -0
  152. package/plugins/invoice/templates/templates.json +11 -0
  153. package/plugins/mail-reader/admin/views/mail.js +19 -0
  154. package/plugins/mail-reader/config.js +7 -0
  155. package/plugins/mail-reader/plugin.js +48 -0
  156. package/plugins/mail-reader/plugin.json +33 -0
  157. package/plugins/notes/admin/views/notes.js +1 -1
  158. package/plugins/notes/plugin.json +2 -2
  159. package/plugins/surveys/lib/audience.js +37 -0
  160. package/plugins/surveys/lib/campaigns.js +43 -0
  161. package/plugins/surveys/lib/ledger.js +110 -0
  162. package/plugins/surveys/lib/sending.js +106 -0
  163. package/plugins/surveys/lib/stats.js +62 -0
  164. package/plugins/surveys/lib/submit.js +95 -0
  165. package/plugins/surveys/lib/tokens.js +28 -0
  166. package/plugins/surveys/plugin.public.js +149 -0
  167. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  168. package/public/css/forms.css +1 -1
  169. package/public/css/menu-highlight.css +1 -1
  170. package/public/css/search.css +1 -0
  171. package/public/css/site.css +1 -1
  172. package/public/js/collection-context.js +2 -2
  173. package/public/js/context-menus.js +1 -0
  174. package/public/js/form-logic-engine.js +1 -1
  175. package/public/js/forms.js +2 -2
  176. package/public/js/menu-decor.mjs +1 -1
  177. package/public/js/search.js +1 -0
  178. package/public/js/site.js +1 -1
  179. package/scripts/build.js +37 -3
  180. package/scripts/copy-domma.js +48 -0
  181. package/scripts/seed.js +1996 -0
  182. package/scripts/setup.js +8 -0
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/context-menus.js +104 -0
  185. package/server/routes/api/forms.js +42 -3
  186. package/server/routes/api/notifications.js +69 -19
  187. package/server/routes/api/plugins.js +50 -6
  188. package/server/routes/api/search.js +43 -0
  189. package/server/routes/api/theme.js +69 -0
  190. package/server/routes/public.js +42 -7
  191. package/server/server.js +74 -0
  192. package/server/services/adapters/FileAdapter.js +6 -1
  193. package/server/services/content.js +26 -0
  194. package/server/services/contextMenus.js +477 -0
  195. package/server/services/email.js +29 -3
  196. package/server/services/health.js +23 -2
  197. package/server/services/markdown.js +70 -9
  198. package/server/services/menuRender.js +28 -4
  199. package/server/services/menus.js +25 -1
  200. package/server/services/permissionRegistry.js +24 -0
  201. package/server/services/pluginFiles.js +52 -11
  202. package/server/services/plugins.js +229 -6
  203. package/server/services/renderer.js +148 -22
  204. package/server/services/roles.js +1 -1
  205. package/server/services/search-migration.js +82 -0
  206. package/server/services/search.js +413 -0
  207. package/server/services/sidebar-migration.js +1 -0
  208. package/server/services/themeSettings.js +541 -0
  209. package/server/services/users.js +8 -0
  210. package/server/templates/page.html +4 -2
  211. package/plugins/contacts/data/contacts.json +0 -20
  212. package/plugins/notes/data/notes.json +0 -1
  213. package/plugins/site-search/admin/views/site-search.js +0 -116
  214. package/plugins/site-search/config.js +0 -15
  215. package/plugins/site-search/plugin.js +0 -188
  216. package/plugins/site-search/plugin.json +0 -40
  217. package/plugins/site-search/public/inject-body.html +0 -17
  218. package/plugins/site-search/public/inject-head.html +0 -1
  219. package/plugins/site-search/public/search.css +0 -1
  220. package/plugins/site-search/public/search.js +0 -1
  221. package/plugins/todo/data/todos.json +0 -1
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Context menus - right-click menus bound to a region of a page.
3
+ *
4
+ * Deliberately NOT part of the menu system. A nav menu renders into a named
5
+ * slot and has a server render path; a context menu has neither. It binds to a
6
+ * CSS selector, exists only in the browser, and is resolved at right-click time
7
+ * by Domma walking outward from the clicked element. So `resolveLocation` and
8
+ * `buildMenuNav` are not reused here - see the domma-menus skill, which says as
9
+ * much.
10
+ *
11
+ * What IS reused is `binding`, in exactly the shape menus already use, so
12
+ * per-page and per-project scoping behaves identically and editors only learn
13
+ * it once.
14
+ *
15
+ * Storage mirrors menus: one file per menu under config/context-menus/.
16
+ */
17
+
18
+ import fs from 'fs/promises';
19
+ import path from 'path';
20
+ import {fileURLToPath} from 'url';
21
+ import * as cache from './cache/index.js';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const CONFIG_DIR = path.resolve(__dirname, '..', '..', 'config');
25
+ const CTX_DIR = path.join(CONFIG_DIR, 'context-menus');
26
+
27
+ const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
28
+
29
+ /** Cached index, invalidated on every write. Mirrors getAllMenus(). */
30
+ let _index = null;
31
+
32
+ export function invalidateContextMenuIndex() {
33
+ _index = null;
34
+ }
35
+
36
+ /**
37
+ * Drop every cached page so an edited menu reaches the front end.
38
+ *
39
+ * `site` is the tag to use: routes/public.js stores each page under
40
+ * [`page:<urlPath>`, ...page.cacheTags, 'nav', 'site'], so `site` is the only
41
+ * one that covers all of them, and a context menu can apply to any page.
42
+ *
43
+ * This was two bugs in one line. It called `invalidateTag` (singular), which
44
+ * the cache module does not export, and it passed 'pages', which nothing is
45
+ * ever tagged with - so even the corrected call would have matched nothing.
46
+ * The catch swallowed the TypeError, and because the cache is off in dev and
47
+ * on in production, editing a menu worked locally and did nothing live for an
48
+ * hour. Failures are logged now: "the cache is optional" is a reason to carry
49
+ * on after a cache error, not a reason to hide one.
50
+ */
51
+ async function invalidatePageCache() {
52
+ try {
53
+ await cache.invalidateTags(['site']);
54
+ } catch (err) {
55
+ console.warn(`[context-menus] Page cache not invalidated: ${err.message}`);
56
+ }
57
+ }
58
+
59
+ export function validateSlug(slug) {
60
+ return typeof slug === 'string' && slug.length <= 64 && SLUG_RE.test(slug);
61
+ }
62
+
63
+ export async function ensureContextMenusDir() {
64
+ await fs.mkdir(CTX_DIR, {recursive: true});
65
+ }
66
+
67
+ function ctxPath(slug) {
68
+ return path.join(CTX_DIR, `${slug}.json`);
69
+ }
70
+
71
+ async function readCtxFile(slug) {
72
+ return JSON.parse(await fs.readFile(ctxPath(slug), 'utf8'));
73
+ }
74
+
75
+ async function writeCtxFile(menu) {
76
+ await ensureContextMenusDir();
77
+ await fs.writeFile(ctxPath(menu.slug), JSON.stringify(menu, null, 2) + '\n', 'utf8');
78
+ invalidateContextMenuIndex();
79
+ await invalidatePageCache();
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Validation
84
+ // ---------------------------------------------------------------------------
85
+
86
+ const INHERIT = new Set(['append', 'prepend', false]);
87
+ const TRANSITIONS = new Set(['scale', 'fade', 'slide', 'none']);
88
+ const DENSITIES = new Set(['comfortable', 'compact']);
89
+ const SCALE = new Set(['none', 'sm', 'md', 'lg', 'xl']);
90
+ const ACCENTS = new Set(['primary', 'success', 'danger', 'warning', 'info']);
91
+ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
92
+
93
+ /*
94
+ * Action types an authored item may carry. Arbitrary JavaScript is deliberately
95
+ * not one of them: admin/ is replaced wholesale by the updater, so anything an
96
+ * editor pasted would be lost on the next update, and a stored script running
97
+ * on public pages is a cross-site scripting hole with extra steps. Anything
98
+ * bespoke goes through `run-action`, which names an Actions artefact that has
99
+ * its own permissions.
100
+ */
101
+ export const ACTION_TYPES = new Set([
102
+ 'navigate', // {url}
103
+ 'filter', // {field, op, value} - collection displays
104
+ 'search', // opens the display's search
105
+ 'sort', // {field, dir}
106
+ 'export', // {format}
107
+ 'copy-link',
108
+ 'print',
109
+ 'open-modal', // {block} - renders a block into a modal
110
+ 'run-action', // {slug} - an Actions artefact
111
+ 'mark-as-spam', // {field} - flags the clicked entry, default field `spam`
112
+
113
+ /*
114
+ * Specials - the things a browser's own context menu offers. They take no
115
+ * arguments and each decides for itself whether it applies to the click:
116
+ * back greys out with no history behind it, paste unless the click landed
117
+ * in something editable. That is `disabled` rather than hidden, so the menu
118
+ * keeps a stable shape and reads the way a native one does.
119
+ */
120
+ 'reload',
121
+ 'back',
122
+ 'forward',
123
+ 'copy', // the current selection
124
+ 'cut',
125
+ 'paste',
126
+ 'select-all',
127
+ 'site-search' // opens the public search overlay (public/js/search.js)
128
+ ]);
129
+
130
+ const ITEM_TYPES = new Set(['item', 'divider', 'header', 'checkbox', 'radio']);
131
+
132
+ /*
133
+ * A selector an editor types is applied with querySelector at right-click time.
134
+ * These are the ones that would bind the menu to the entire document and make
135
+ * every other menu on the page unreachable - a mistake that looks like "context
136
+ * menus are broken" rather than like a typo.
137
+ */
138
+ const FORBIDDEN_SELECTORS = new Set(['*', 'html', 'body', ':root', 'head']);
139
+
140
+ function isPlainObject(v) {
141
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
142
+ }
143
+
144
+ /**
145
+ * Validate a context menu. Returns an array of human-readable errors; empty
146
+ * means valid. Mirrors validateMenu()'s contract so the routes handle both the
147
+ * same way.
148
+ *
149
+ * @param {object} menu
150
+ * @returns {string[]}
151
+ */
152
+ export function validateContextMenu(menu) {
153
+ const errors = [];
154
+
155
+ if (!isPlainObject(menu)) return ['Context menu must be an object'];
156
+ if (!validateSlug(menu.slug)) errors.push(`Invalid slug: ${menu.slug}`);
157
+ if (!menu.name || typeof menu.name !== 'string') errors.push('Name is required');
158
+
159
+ // --- target ---
160
+ const t = menu.target;
161
+ if (!isPlainObject(t)) {
162
+ errors.push('target is required');
163
+ } else {
164
+ if (!t.selector || typeof t.selector !== 'string') {
165
+ errors.push('target.selector is required');
166
+ } else {
167
+ const sel = t.selector.trim();
168
+ if (FORBIDDEN_SELECTORS.has(sel.toLowerCase())) {
169
+ errors.push(`target.selector "${sel}" would bind the whole page`);
170
+ }
171
+ for (const key of ['selector', 'match', 'exclude']) {
172
+ const v = t[key];
173
+ if (v === undefined || v === null || v === '') continue;
174
+ if (typeof v !== 'string') { errors.push(`target.${key} must be a string`); continue; }
175
+ try {
176
+ document?.querySelector?.(v);
177
+ } catch { /* no DOM on the server - checked below instead */ }
178
+ // Cheap structural check: a selector with unbalanced brackets is
179
+ // a typo that would otherwise throw inside the visitor's browser.
180
+ const balanced = (a, b) => (v.split(a).length === v.split(b).length);
181
+ if (!balanced('[', ']') || !balanced('(', ')')) {
182
+ errors.push(`target.${key} is not a valid selector: ${v}`);
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ // --- cascade ---
189
+ if (menu.inherit !== undefined && !INHERIT.has(menu.inherit)) {
190
+ errors.push('inherit must be "append", "prepend" or false');
191
+ }
192
+ if (menu.priority !== undefined && !Number.isInteger(menu.priority)) {
193
+ errors.push('priority must be an integer');
194
+ }
195
+
196
+ // --- theme ---
197
+ const th = menu.theme;
198
+ if (th !== undefined) {
199
+ if (!isPlainObject(th)) {
200
+ errors.push('theme must be an object');
201
+ } else {
202
+ if (th.accent && !ACCENTS.has(th.accent) && !HEX_RE.test(th.accent)) {
203
+ errors.push(`theme.accent must be a preset key or #rrggbb: ${th.accent}`);
204
+ }
205
+ for (const key of ['radius', 'shadow']) {
206
+ if (th[key] && !SCALE.has(th[key])) {
207
+ errors.push(`theme.${key} must be one of none, sm, md, lg, xl`);
208
+ }
209
+ }
210
+ if (th.density && !DENSITIES.has(th.density)) {
211
+ errors.push('theme.density must be "comfortable" or "compact"');
212
+ }
213
+ if (th.transition && !TRANSITIONS.has(th.transition)) {
214
+ errors.push('theme.transition must be scale, fade, slide or none');
215
+ }
216
+ if (th.opacity !== undefined && th.opacity !== null) {
217
+ const n = Number(th.opacity);
218
+ if (!Number.isFinite(n) || n < 20 || n > 100) {
219
+ errors.push('theme.opacity must be between 20 and 100');
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ // --- items ---
226
+ if (!Array.isArray(menu.items)) {
227
+ errors.push('items must be an array');
228
+ } else {
229
+ validateItems(menu.items, errors, 'items');
230
+ const usable = menu.items.filter(
231
+ (i) => isPlainObject(i) && !['divider', 'header'].includes(i.type)
232
+ );
233
+ if (!usable.length) {
234
+ errors.push('A menu needs at least one item that is not a divider or header');
235
+ }
236
+ }
237
+
238
+ return errors;
239
+ }
240
+
241
+ function validateItems(items, errors, trail, depth = 0) {
242
+ if (depth > 4) { errors.push(`${trail}: submenus nested more than 4 deep`); return; }
243
+
244
+ items.forEach((item, i) => {
245
+ const at = `${trail}[${i}]`;
246
+ if (!isPlainObject(item)) { errors.push(`${at} must be an object`); return; }
247
+
248
+ const type = item.type || 'item';
249
+ if (!ITEM_TYPES.has(type)) { errors.push(`${at}.type "${type}" is not recognised`); return; }
250
+ if (type === 'divider') return;
251
+ if (!item.text || typeof item.text !== 'string') errors.push(`${at}.text is required`);
252
+ if (type === 'header') return;
253
+
254
+ const hasSub = Array.isArray(item.submenu) && item.submenu.length;
255
+ if (hasSub) {
256
+ validateItems(item.submenu, errors, `${at}.submenu`, depth + 1);
257
+ return; // a parent carries its children, not an action
258
+ }
259
+
260
+ const a = item.action;
261
+ if (!isPlainObject(a)) {
262
+ errors.push(`${at}.action is required (or give it a submenu)`);
263
+ } else if (!ACTION_TYPES.has(a.type)) {
264
+ errors.push(`${at}.action.type "${a.type}" is not recognised`);
265
+ } else {
266
+ if (a.type === 'navigate' && !a.url) errors.push(`${at}.action.url is required`);
267
+ if (a.type === 'run-action' && !a.slug) errors.push(`${at}.action.slug is required`);
268
+ if (a.type === 'open-modal' && !a.block) errors.push(`${at}.action.block is required`);
269
+ }
270
+ });
271
+ }
272
+
273
+ function normaliseBinding(binding) {
274
+ if (!isPlainObject(binding)) return undefined;
275
+ const out = {};
276
+ if (Array.isArray(binding.projects)) out.projects = binding.projects.filter((p) => typeof p === 'string');
277
+ if (Array.isArray(binding.pages)) out.pages = binding.pages.filter((p) => typeof p === 'string');
278
+ return (out.projects?.length || out.pages?.length) ? out : undefined;
279
+ }
280
+
281
+ function shape(input) {
282
+ const menu = {
283
+ slug: input.slug,
284
+ name: input.name,
285
+ description: input.description || '',
286
+ enabled: input.enabled !== false,
287
+ target: {
288
+ selector: String(input.target?.selector || '').trim(),
289
+ ...(input.target?.match && {match: String(input.target.match).trim()}),
290
+ ...(input.target?.exclude && {exclude: String(input.target.exclude).trim()})
291
+ },
292
+ inherit: input.inherit === undefined ? 'append' : input.inherit,
293
+ ...(Number.isInteger(input.priority) && input.priority !== 0 && {priority: input.priority}),
294
+ ...(isPlainObject(input.theme) && Object.keys(input.theme).length && {theme: input.theme}),
295
+ items: Array.isArray(input.items) ? input.items : []
296
+ };
297
+ const binding = normaliseBinding(input.binding);
298
+ if (binding) menu.binding = binding;
299
+ if (isPlainObject(input.meta) && Object.keys(input.meta).length) menu.meta = input.meta;
300
+ return menu;
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // CRUD
305
+ // ---------------------------------------------------------------------------
306
+
307
+ /** List all context menus, metadata only. Sorted by slug. */
308
+ export async function listContextMenus() {
309
+ await ensureContextMenusDir();
310
+ const files = await fs.readdir(CTX_DIR);
311
+ const out = [];
312
+ for (const file of files) {
313
+ if (!file.endsWith('.json')) continue;
314
+ const slug = file.slice(0, -5);
315
+ if (!validateSlug(slug)) continue;
316
+ try {
317
+ const menu = await readCtxFile(slug);
318
+ out.push({
319
+ slug: menu.slug || slug,
320
+ name: menu.name || '',
321
+ description: menu.description || '',
322
+ enabled: menu.enabled !== false,
323
+ selector: menu.target?.selector || '',
324
+ match: menu.target?.match || '',
325
+ itemsCount: Array.isArray(menu.items) ? menu.items.length : 0,
326
+ ...(menu.binding && {binding: menu.binding}),
327
+ meta: menu.meta || {}
328
+ });
329
+ } catch (err) {
330
+ console.warn(`[context-menus] Skipping malformed menu "${slug}": ${err.message}`);
331
+ }
332
+ }
333
+ return out.sort((a, b) => a.slug.localeCompare(b.slug));
334
+ }
335
+
336
+ /** Every menu in full, cached in-process. Used by the page resolver. */
337
+ export async function getAllContextMenus() {
338
+ if (_index) return _index;
339
+ await ensureContextMenusDir();
340
+ const files = await fs.readdir(CTX_DIR);
341
+ const out = [];
342
+ for (const file of files) {
343
+ if (!file.endsWith('.json')) continue;
344
+ const slug = file.slice(0, -5);
345
+ if (!validateSlug(slug)) continue;
346
+ try {
347
+ out.push(await readCtxFile(slug));
348
+ } catch { /* listContextMenus() already warns */ }
349
+ }
350
+ _index = out;
351
+ return out;
352
+ }
353
+
354
+ export async function getContextMenu(slug) {
355
+ if (!validateSlug(slug)) return null;
356
+ try {
357
+ return await readCtxFile(slug);
358
+ } catch (err) {
359
+ if (err.code === 'ENOENT') return null;
360
+ throw err;
361
+ }
362
+ }
363
+
364
+ export async function createContextMenu(input) {
365
+ if (!validateSlug(input?.slug)) throw new Error(`Invalid slug: ${input?.slug}`);
366
+ if (await getContextMenu(input.slug)) throw new Error(`Context menu "${input.slug}" already exists`);
367
+
368
+ const menu = shape(input);
369
+ const errors = validateContextMenu(menu);
370
+ if (errors.length) throw new Error(errors.join('; '));
371
+
372
+ await writeCtxFile(menu);
373
+ return menu;
374
+ }
375
+
376
+ export async function updateContextMenu(slug, input) {
377
+ const existing = await getContextMenu(slug);
378
+ if (!existing) throw new Error(`Context menu "${slug}" not found`);
379
+
380
+ // The slug is the filename and the identity; renaming is a delete plus a
381
+ // create, not an edit.
382
+ const menu = shape({...existing, ...input, slug});
383
+ const errors = validateContextMenu(menu);
384
+ if (errors.length) throw new Error(errors.join('; '));
385
+
386
+ await writeCtxFile(menu);
387
+ return menu;
388
+ }
389
+
390
+ export async function deleteContextMenu(slug) {
391
+ if (!validateSlug(slug)) throw new Error(`Invalid slug: ${slug}`);
392
+ try {
393
+ await fs.unlink(ctxPath(slug));
394
+ } catch (err) {
395
+ if (err.code === 'ENOENT') throw new Error(`Context menu "${slug}" not found`);
396
+ throw err;
397
+ }
398
+ invalidateContextMenuIndex();
399
+ await invalidatePageCache();
400
+ return true;
401
+ }
402
+
403
+ export async function duplicateContextMenu(slug) {
404
+ const source = await getContextMenu(slug);
405
+ if (!source) throw new Error(`Context menu "${slug}" not found`);
406
+
407
+ let candidate = `${slug}-copy`;
408
+ let n = 2;
409
+ while (await getContextMenu(candidate)) candidate = `${slug}-copy-${n++}`;
410
+
411
+ return createContextMenu({...source, slug: candidate, name: `${source.name} (copy)`});
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Page resolution
416
+ // ---------------------------------------------------------------------------
417
+
418
+ /** `/shop/*` matches `/shop/thing`; an exact path matches itself. */
419
+ function matchesPage(pattern, urlPath) {
420
+ if (!pattern || !urlPath) return false;
421
+ if (pattern === urlPath) return true;
422
+ if (pattern.endsWith('/*')) return urlPath.startsWith(pattern.slice(0, -1));
423
+ return false;
424
+ }
425
+
426
+ /**
427
+ * Every context menu that applies to a page.
428
+ *
429
+ * Unlike a slot, this is NOT one-menu-wins: several can apply at once and
430
+ * Domma decides which answers a given right-click by walking outward from the
431
+ * click. That is the same resolution the `overlay` slot uses, one level down -
432
+ * region rather than slot.
433
+ *
434
+ * A menu with no binding is site-wide. A menu with a binding applies only where
435
+ * it matches, so binding is a narrowing, never a widening.
436
+ *
437
+ * @param {{urlPath?: string, project?: string}} ctx
438
+ * @returns {Promise<object[]>} payloads for the browser, in binding order
439
+ */
440
+ export async function resolveContextMenusForPage(ctx = {}) {
441
+ const all = await getAllContextMenus();
442
+ const {urlPath, project} = ctx;
443
+
444
+ const applies = all.filter((menu) => {
445
+ if (menu.enabled === false) return false;
446
+ const b = menu.binding;
447
+ if (!b) return true; // site-wide
448
+ if (b.pages?.length && urlPath && b.pages.some((p) => matchesPage(p, urlPath))) return true;
449
+ if (b.projects?.length && project && b.projects.includes(project)) return true;
450
+ return false;
451
+ });
452
+
453
+ /*
454
+ * A bound menu is more specific than a site-wide one, so it is sent later:
455
+ * where two menus bind the same element Domma breaks the tie on `priority`
456
+ * and then on registration order, and the more specific one should win.
457
+ */
458
+ applies.sort((a, b) => Number(Boolean(a.binding)) - Number(Boolean(b.binding)));
459
+
460
+ return applies.map(toClientPayload);
461
+ }
462
+
463
+ /**
464
+ * The browser needs the binding and the items, not the editor's metadata.
465
+ * Keeping the payload narrow also keeps it out of the page source for menus a
466
+ * visitor cannot use.
467
+ */
468
+ export function toClientPayload(menu) {
469
+ return {
470
+ slug: menu.slug,
471
+ target: menu.target,
472
+ inherit: menu.inherit === undefined ? 'append' : menu.inherit,
473
+ ...(menu.priority && {priority: menu.priority}),
474
+ ...(menu.theme && {theme: menu.theme}),
475
+ items: menu.items || []
476
+ };
477
+ }
@@ -45,6 +45,21 @@ export function escapeHtml(str) {
45
45
  .replace(/'/g, '&#39;');
46
46
  }
47
47
 
48
+ /**
49
+ * Does this site have somewhere real to send mail?
50
+ *
51
+ * The host is the only field that decides it: everything else has a usable
52
+ * default, and an empty host is what sends the whole site to Ethereal. Shared
53
+ * with the health check and the admin so all three agree on what "configured"
54
+ * means.
55
+ *
56
+ * @param {{host?: string}} smtp
57
+ * @returns {boolean}
58
+ */
59
+ export function isSmtpConfigured(smtp) {
60
+ return typeof smtp?.host === 'string' && smtp.host.trim() !== '';
61
+ }
62
+
48
63
  /**
49
64
  * Create a nodemailer transport.
50
65
  * Falls back to an Ethereal test account when no SMTP host is configured.
@@ -54,7 +69,7 @@ export function escapeHtml(str) {
54
69
  * @throws {Error} If Ethereal test account creation fails.
55
70
  */
56
71
  export async function createTransport(smtp) {
57
- if (smtp?.host) {
72
+ if (isSmtpConfigured(smtp)) {
58
73
  return nodemailer.createTransport({
59
74
  host: smtp.host,
60
75
  port: smtp.port || 587,
@@ -64,9 +79,20 @@ export async function createTransport(smtp) {
64
79
  });
65
80
  }
66
81
 
67
- // No SMTP configured - use Ethereal for dev/demo
82
+ // No SMTP configured - use Ethereal for dev/demo.
83
+ //
84
+ // This is the quiet failure mode of the whole email feature: Ethereal
85
+ // accepts the message, nodemailer reports success, the health check records
86
+ // a good send, and nothing is delivered to anyone. Password resets and form
87
+ // notifications all "work". Say so at warn level rather than as a friendly
88
+ // info line, and say it in terms of the consequence - the admin carries the
89
+ // same warning on the Email tab, and checkSmtpConfig() surfaces it in
90
+ // Health. A site scaffolded by the CLI or the fleet manager ships with a
91
+ // host precisely so it never lands here.
68
92
  const testAccount = await nodemailer.createTestAccount();
69
- console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
93
+ console.warn('[email] NO SMTP HOST CONFIGURED - mail is going to an Ethereal test inbox '
94
+ + 'and will NOT reach its recipients. Set one in Settings > Email. '
95
+ + 'Ethereal account: ' + testAccount.user);
70
96
  return nodemailer.createTransport({
71
97
  host: 'smtp.ethereal.email',
72
98
  port: 587,
@@ -6,8 +6,8 @@
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
- import {config} from '../config.js';
10
- import {getLastSendResult} from './email.js';
9
+ import {config, getConfig} from '../config.js';
10
+ import {getLastSendResult, isSmtpConfigured} from './email.js';
11
11
  import {listPages} from './content.js';
12
12
  import {getPluginLoadFailures} from './plugins.js';
13
13
  import {FORMS_DIR} from './forms.js';
@@ -203,6 +203,26 @@ async function checkSmtp() {
203
203
  };
204
204
  }
205
205
 
206
+ // Distinct from checkSmtp above, which reports the LAST SEND. That check goes
207
+ // green on an Ethereal send, because as far as nodemailer is concerned the
208
+ // message was accepted - so on a site with no relay the two checks disagree on
209
+ // purpose: "last email send: ok" next to "SMTP host: not configured" is the
210
+ // whole story, and neither half tells it alone.
211
+ async function checkSmtpConfig() {
212
+ const smtp = getConfig('site')?.smtp;
213
+ if (isSmtpConfigured(smtp)) {
214
+ return { id: 'smtp-config', level: 'ok', label: 'SMTP host', value: smtp.host };
215
+ }
216
+ return {
217
+ id: 'smtp-config',
218
+ level: 'warn',
219
+ label: 'SMTP host',
220
+ value: 'not configured',
221
+ detail: ['Mail is going to an Ethereal test inbox and will not reach its recipients.',
222
+ 'Set a host in Settings > Email.']
223
+ };
224
+ }
225
+
206
226
  async function checkPluginLoad() {
207
227
  const failures = getPluginLoadFailures();
208
228
  return {
@@ -265,6 +285,7 @@ export async function getHealth() {
265
285
  safeCheck('missing-meta', checkMissingMeta),
266
286
  safeCheck('last-form', checkLastForm),
267
287
  safeCheck('smtp', checkSmtp),
288
+ safeCheck('smtp-config', checkSmtpConfig),
268
289
  safeCheck('plugin-load', checkPluginLoad)
269
290
  ]);
270
291
  const value = { status: worstLevel(checks), checks };