domma-cms 0.55.1 → 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 (211) hide show
  1. package/CLAUDE.md +130 -2
  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/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. package/plugins/todo/data/todos.json +0 -1
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Theme settings - config/theme.json.
3
+ *
4
+ * Everything that decides how the site and the admin LOOK: the front-end
5
+ * theme, the admin theme, the automatic day/night pair, the font, and the
6
+ * per-element overrides. It used to live scattered through config/site.json
7
+ * next to SMTP credentials and cookie-banner copy; migrateThemeConfig() lifts
8
+ * it out on first boot.
9
+ *
10
+ * site.json keeps a MIRROR of the plain keys (theme, adminTheme, fontFamily,
11
+ * fontSize, autoTheme, baseTheme), rewritten on every save. Nothing in the CMS
12
+ * reads it any more, but plugins, the fleet manager and the Theme Roller all
13
+ * do, and silently changing what they read is not worth the tidiness. theme.json
14
+ * is the authoritative copy; the mirror is one-way and never read back except
15
+ * by the migration.
16
+ *
17
+ * ---------------------------------------------------------------------------
18
+ * Overrides
19
+ * ---------------------------------------------------------------------------
20
+ * A Domma theme is nothing but a class-scoped block of custom properties:
21
+ *
22
+ * .dm-theme-ocean-dark { --dm-primary: #00b4d8; --dm-surface: #1e293b; ... }
23
+ *
24
+ * So re-theming one region is not a CSS-authoring problem, it is a scoping
25
+ * problem: declare the same properties on a selector and everything inside it
26
+ * follows, including components rendered long afterwards. That is why an
27
+ * override is expressed as a selector plus either a theme to adopt or a set of
28
+ * token values - NOT as hand-written rules fighting the cascade, which is how
29
+ * the navbar hover bug happened.
30
+ *
31
+ * Each override compiles to one rule:
32
+ *
33
+ * {scope: '#site-navbar', theme: 'ocean-light', tokens: {'--dm-primary': '#09c'}}
34
+ * -> #site-navbar { <every token of ocean-light>; --dm-primary: #09c; }
35
+ *
36
+ * `css` is the escape hatch for the rare thing tokens cannot say. It is
37
+ * appended verbatim after the generated rule, so the author owns its selectors.
38
+ *
39
+ * KNOWN LIMIT: a handful of Domma rules key off an ANCESTOR mode rather than
40
+ * tokens (`[data-mode="dark"] .alert-info`, the syntax-highlight palette).
41
+ * Scoping a light theme into a dark page re-colours everything token-driven,
42
+ * but those few rules still follow the page. Documented in docs/theming.md
43
+ * rather than papered over with generated counter-rules.
44
+ */
45
+
46
+ import fs from 'fs/promises';
47
+ import fsSync from 'fs';
48
+ import path from 'path';
49
+ import {fileURLToPath} from 'url';
50
+ import {getConfig, saveConfig} from '../config.js';
51
+ import * as cache from './cache/index.js';
52
+
53
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
54
+ const ROOT = path.resolve(__dirname, '..', '..');
55
+ const CONFIG_DIR = path.join(ROOT, 'config');
56
+ const THEME_FILE = path.join(CONFIG_DIR, 'theme.json');
57
+
58
+ /**
59
+ * The shipped theme stylesheet - the single source of truth for which themes
60
+ * exist and which tokens they declare. Read from the installed domma-js
61
+ * package so it can never drift from the CSS the browser actually loads.
62
+ */
63
+ const THEME_CSS_CANDIDATES = [
64
+ path.join(ROOT, 'node_modules', 'domma-js', 'public', 'dist', 'themes', 'domma-themes.css'),
65
+ path.join(ROOT, 'public', 'dist', 'domma', 'themes', 'domma-themes.css')
66
+ ];
67
+
68
+ const TOKEN_RE = /^--dm-[a-z0-9-]+$/;
69
+ const DEFAULT_THEME = 'charcoal-dark';
70
+
71
+ /** Selectors that would swallow the whole document rather than scope anything. */
72
+ const FORBIDDEN_SCOPES = new Set(['*', ':root', 'html', 'html *', '*, *::before, *::after']);
73
+
74
+ export function defaultThemeConfig() {
75
+ return {
76
+ version: 1,
77
+ theme: DEFAULT_THEME,
78
+ adminTheme: DEFAULT_THEME,
79
+ baseTheme: null,
80
+ autoTheme: {
81
+ enabled: false,
82
+ dayTheme: 'charcoal-light',
83
+ nightTheme: 'charcoal-dark',
84
+ dayStart: '07:00',
85
+ nightStart: '19:00'
86
+ },
87
+ font: {family: 'Roboto', size: 16},
88
+ overrides: []
89
+ };
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Theme stylesheet introspection
94
+ // ---------------------------------------------------------------------------
95
+
96
+ let _cssCache = null;
97
+
98
+ function readThemeStylesheet() {
99
+ if (_cssCache !== null) return _cssCache;
100
+ for (const file of THEME_CSS_CANDIDATES) {
101
+ try {
102
+ _cssCache = fsSync.readFileSync(file, 'utf8');
103
+ return _cssCache;
104
+ } catch { /* try the next candidate */ }
105
+ }
106
+ _cssCache = '';
107
+ return _cssCache;
108
+ }
109
+
110
+ /** Drop the memoised stylesheet - after an upgrade, or in tests. */
111
+ export function invalidateThemeStylesheet() {
112
+ _cssCache = null;
113
+ _themeIdCache = null;
114
+ _tokenNameCache = null;
115
+ }
116
+
117
+ let _themeIdCache = null;
118
+
119
+ /**
120
+ * Every theme id the shipped stylesheet defines, e.g. 'ocean-dark'.
121
+ *
122
+ * @returns {string[]}
123
+ */
124
+ export function listThemeIds() {
125
+ if (_themeIdCache) return _themeIdCache;
126
+ const css = readThemeStylesheet();
127
+ const ids = new Set();
128
+ for (const m of css.matchAll(/^\.dm-theme-([a-z0-9-]+)\s*\{/gm)) ids.add(m[1]);
129
+ _themeIdCache = [...ids];
130
+ return _themeIdCache;
131
+ }
132
+
133
+ let _tokenNameCache = null;
134
+
135
+ /**
136
+ * Every `--dm-*` token the stylesheet declares. The admin picker builds itself
137
+ * from this, so an override can only name a token that actually exists - an
138
+ * invented one resolves to nothing and produces the blank-popup class of bug.
139
+ *
140
+ * @returns {string[]}
141
+ */
142
+ export function listThemeTokens() {
143
+ if (_tokenNameCache) return _tokenNameCache;
144
+ const css = readThemeStylesheet();
145
+ const names = new Set();
146
+ for (const m of css.matchAll(/(--dm-[a-z0-9-]+)\s*:/g)) names.add(m[1]);
147
+ _tokenNameCache = [...names].sort();
148
+ return _tokenNameCache;
149
+ }
150
+
151
+ /**
152
+ * The declaration body of one theme's class block, ready to be re-scoped.
153
+ *
154
+ * Returns the raw text between the braces (`--dm-primary: #...; ...`), or null
155
+ * when the theme is unknown - a renamed or removed theme must not take the
156
+ * whole stylesheet down with it.
157
+ *
158
+ * @param {string} themeId
159
+ * @returns {string|null}
160
+ */
161
+ export function getThemeDeclarations(themeId) {
162
+ if (!themeId || !/^[a-z0-9-]+$/.test(themeId)) return null;
163
+ const css = readThemeStylesheet();
164
+ // Anchored so `.dm-theme-x .child {}` descendant rules cannot be mistaken
165
+ // for the theme's own block, and tolerant of the whitespace a rebuild or a
166
+ // minifier might change.
167
+ const re = new RegExp(`^\\.dm-theme-${themeId}\\s*\\{([^}]*)\\}`, 'm');
168
+ const m = css.match(re);
169
+ if (!m) return null;
170
+ // The source block is heavily commented for humans reading the stylesheet.
171
+ // Copying those comments into every page would add kilobytes per override
172
+ // for nobody's benefit.
173
+ return m[1]
174
+ .replace(/\/\*[\s\S]*?\*\//g, '')
175
+ .split('\n')
176
+ .map((line) => line.trim())
177
+ .filter(Boolean)
178
+ .join('\n')
179
+ .trim();
180
+ }
181
+
182
+ /**
183
+ * One theme's tokens as a map, for comparing two themes.
184
+ *
185
+ * The admin uses this to answer the question that "adopt a theme here" quietly
186
+ * raises: WHICH tokens does this actually change? Two light themes commonly
187
+ * share a white surface, so adopting one onto a region can legitimately leave
188
+ * the thing you were looking at - the fill - exactly as it was.
189
+ *
190
+ * @param {string} themeId
191
+ * @returns {Object<string,string>|null}
192
+ */
193
+ export function getThemeTokenMap(themeId) {
194
+ const decls = getThemeDeclarations(themeId);
195
+ if (decls === null) return null;
196
+ const tokens = {};
197
+ for (const m of decls.matchAll(/(--dm-[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
198
+ tokens[m[1]] = m[2].trim();
199
+ }
200
+ return tokens;
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Load / save / migrate
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Spreading a partial over defaults is not enough: a key present with the value
209
+ * `undefined` wins the spread and erases the default, which is exactly what a
210
+ * site.json missing `theme` produces on its way through themeConfigFromSite.
211
+ *
212
+ * @param {object} obj
213
+ * @returns {object}
214
+ */
215
+ function definedOnly(obj) {
216
+ const out = {};
217
+ for (const [k, v] of Object.entries(obj || {})) if (v !== undefined) out[k] = v;
218
+ return out;
219
+ }
220
+
221
+ function mergeDefaults(raw) {
222
+ const d = defaultThemeConfig();
223
+ const cfg = {...d, ...definedOnly(raw)};
224
+ cfg.autoTheme = {...d.autoTheme, ...definedOnly(raw?.autoTheme)};
225
+ cfg.font = {...d.font, ...definedOnly(raw?.font)};
226
+ cfg.overrides = Array.isArray(raw?.overrides) ? raw.overrides : [];
227
+ return cfg;
228
+ }
229
+
230
+ /**
231
+ * Build a theme config from the legacy site.json keys. Used by the migration
232
+ * and as the fallback when config/theme.json has not been written yet.
233
+ *
234
+ * @param {object} site
235
+ * @returns {object}
236
+ */
237
+ export function themeConfigFromSite(site = {}) {
238
+ return mergeDefaults({
239
+ theme: site.theme,
240
+ adminTheme: site.adminTheme,
241
+ baseTheme: site.baseTheme || null,
242
+ autoTheme: site.autoTheme,
243
+ font: {family: site.fontFamily, size: site.fontSize},
244
+ overrides: []
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Read the theme config. Falls back to the legacy site.json keys when
250
+ * config/theme.json is absent, so a site that has not run the migration (or an
251
+ * unpacked archive that predates it) still renders with the right theme.
252
+ *
253
+ * @returns {Promise<object>}
254
+ */
255
+ export async function loadThemeConfig() {
256
+ try {
257
+ return mergeDefaults(JSON.parse(await fs.readFile(THEME_FILE, 'utf8')));
258
+ } catch {
259
+ try {
260
+ return themeConfigFromSite(getConfig('site'));
261
+ } catch {
262
+ return defaultThemeConfig();
263
+ }
264
+ }
265
+ }
266
+
267
+ /** Synchronous twin of loadThemeConfig, for the render path. */
268
+ export function loadThemeConfigSync() {
269
+ try {
270
+ return mergeDefaults(JSON.parse(fsSync.readFileSync(THEME_FILE, 'utf8')));
271
+ } catch {
272
+ try {
273
+ return themeConfigFromSite(getConfig('site'));
274
+ } catch {
275
+ return defaultThemeConfig();
276
+ }
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Validate and persist. Throws on the mistakes that look like breakage rather
282
+ * than typos; unknown keys are dropped rather than stored.
283
+ *
284
+ * @param {object} input
285
+ * @returns {Promise<object>} The stored config.
286
+ */
287
+ export async function saveThemeConfig(input) {
288
+ const errors = validateThemeConfig(input);
289
+ if (errors.length) {
290
+ const err = new Error(errors.join('; '));
291
+ err.validation = errors;
292
+ throw err;
293
+ }
294
+
295
+ const current = await loadThemeConfig();
296
+ const cfg = mergeDefaults({
297
+ ...current,
298
+ ...input,
299
+ autoTheme: {...current.autoTheme, ...(input.autoTheme || {})},
300
+ font: {...current.font, ...(input.font || {})},
301
+ overrides: Array.isArray(input.overrides) ? input.overrides.map(normaliseOverride) : current.overrides
302
+ });
303
+ cfg.version = 1;
304
+
305
+ await fs.mkdir(CONFIG_DIR, {recursive: true});
306
+ await fs.writeFile(THEME_FILE, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
307
+ mirrorToSite(cfg);
308
+ await invalidateThemeCache();
309
+ return cfg;
310
+ }
311
+
312
+ /**
313
+ * Rewrite the legacy site.json keys so third parties that still read them see
314
+ * the current values. One-way: nothing reads these back.
315
+ *
316
+ * @param {object} cfg
317
+ * @returns {void}
318
+ */
319
+ function mirrorToSite(cfg) {
320
+ try {
321
+ const site = getConfig('site');
322
+ site.theme = cfg.theme;
323
+ site.adminTheme = cfg.adminTheme;
324
+ site.fontFamily = cfg.font.family;
325
+ site.fontSize = cfg.font.size;
326
+ site.autoTheme = cfg.autoTheme;
327
+ if (cfg.baseTheme) site.baseTheme = cfg.baseTheme;
328
+ else delete site.baseTheme;
329
+ saveConfig('site', site);
330
+ } catch {
331
+ // site.json being unwritable must not fail a theme save - theme.json is
332
+ // the authoritative copy and has already landed.
333
+ }
334
+ }
335
+
336
+ async function invalidateThemeCache() {
337
+ try {
338
+ // `site` only: routes/public.js tags every page with it. There is no
339
+ // 'pages' tag - passing one looks like belt and braces and is a no-op.
340
+ await cache.invalidateTags(['site']);
341
+ } catch (err) {
342
+ console.warn(`[theme] Page cache not invalidated: ${err.message}`);
343
+ }
344
+ }
345
+
346
+ /**
347
+ * One-time lift of the theme keys out of config/site.json. No-op once
348
+ * config/theme.json exists, so it is safe to call on every boot.
349
+ *
350
+ * @returns {Promise<boolean>} True when a file was written.
351
+ */
352
+ export async function migrateThemeConfig() {
353
+ try {
354
+ await fs.access(THEME_FILE);
355
+ return false;
356
+ } catch { /* not migrated yet */ }
357
+
358
+ let site = {};
359
+ try {
360
+ site = getConfig('site');
361
+ } catch { /* no site.json - defaults it is */ }
362
+
363
+ const cfg = themeConfigFromSite(site);
364
+ await fs.mkdir(CONFIG_DIR, {recursive: true});
365
+ await fs.writeFile(THEME_FILE, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
366
+ return true;
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Validation
371
+ // ---------------------------------------------------------------------------
372
+
373
+ function normaliseOverride(o) {
374
+ const out = {
375
+ id: typeof o?.id === 'string' && o.id ? o.id : `ov-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
376
+ label: String(o?.label || '').slice(0, 120),
377
+ enabled: o?.enabled !== false,
378
+ scope: String(o?.scope || '').trim(),
379
+ theme: typeof o?.theme === 'string' && o.theme ? o.theme : null,
380
+ tokens: {},
381
+ css: typeof o?.css === 'string' ? o.css : ''
382
+ };
383
+ for (const [k, v] of Object.entries(o?.tokens || {})) {
384
+ if (TOKEN_RE.test(k) && typeof v === 'string' && v.trim()) out.tokens[k] = v.trim();
385
+ }
386
+ return out;
387
+ }
388
+
389
+ /**
390
+ * A CSS value that cannot break out of its declaration. Braces and semicolons
391
+ * would end the rule; `<` would end the style element; url()/@import would
392
+ * fetch. Refused rather than escaped, because every legitimate token value is
393
+ * a colour, a length or a short keyword list.
394
+ *
395
+ * @param {string} value
396
+ * @returns {boolean}
397
+ */
398
+ function isSafeTokenValue(value) {
399
+ if (value.length > 200) return false;
400
+ if (/[{}<>;]/.test(value)) return false;
401
+ return !/@import|javascript:|expression\s*\(|url\s*\(/i.test(value);
402
+ }
403
+
404
+ /**
405
+ * Validate a whole theme config. Returns a list of human-readable problems -
406
+ * empty means valid.
407
+ *
408
+ * @param {object} input
409
+ * @returns {string[]}
410
+ */
411
+ export function validateThemeConfig(input) {
412
+ const errors = [];
413
+ if (!input || typeof input !== 'object') return ['Theme config must be an object'];
414
+
415
+ const known = listThemeIds();
416
+ const checkTheme = (value, label) => {
417
+ if (value === undefined || value === null || value === '') return;
418
+ if (typeof value !== 'string' || !/^[a-z0-9-]+$/.test(value)) {
419
+ errors.push(`${label} is not a valid theme id`);
420
+ } else if (known.length && !known.includes(value)) {
421
+ errors.push(`${label}: unknown theme "${value}"`);
422
+ }
423
+ };
424
+
425
+ checkTheme(input.theme, 'Front-end theme');
426
+ checkTheme(input.adminTheme, 'Admin theme');
427
+ if (input.baseTheme) checkTheme(input.baseTheme, 'Base theme');
428
+ if (input.autoTheme) {
429
+ checkTheme(input.autoTheme.dayTheme, 'Day theme');
430
+ checkTheme(input.autoTheme.nightTheme, 'Night theme');
431
+ for (const key of ['dayStart', 'nightStart']) {
432
+ const v = input.autoTheme[key];
433
+ if (v !== undefined && !/^\d{2}:\d{2}$/.test(String(v))) {
434
+ errors.push(`autoTheme.${key} must be HH:MM`);
435
+ }
436
+ }
437
+ }
438
+ if (input.font) {
439
+ const size = input.font.size;
440
+ if (size !== undefined && (!Number.isFinite(Number(size)) || Number(size) < 8 || Number(size) > 48)) {
441
+ errors.push('Font size must be between 8 and 48');
442
+ }
443
+ if (input.font.family !== undefined && !/^[\w \-',]{0,80}$/.test(String(input.font.family))) {
444
+ errors.push('Font family contains unexpected characters');
445
+ }
446
+ }
447
+
448
+ if (input.overrides !== undefined) {
449
+ if (!Array.isArray(input.overrides)) return errors.concat('overrides must be an array');
450
+ if (input.overrides.length > 100) errors.push('At most 100 overrides');
451
+ input.overrides.forEach((raw, i) => {
452
+ const name = raw?.label || `Override ${i + 1}`;
453
+ const scope = String(raw?.scope || '').trim();
454
+ if (!scope) {
455
+ errors.push(`${name}: a target selector is required`);
456
+ } else if (FORBIDDEN_SCOPES.has(scope.toLowerCase())) {
457
+ errors.push(`${name}: "${scope}" would re-theme the whole document - set the site theme instead`);
458
+ } else if (scope.length > 300) {
459
+ errors.push(`${name}: selector is too long`);
460
+ } else if (/[{}<>]/.test(scope)) {
461
+ errors.push(`${name}: selector contains invalid characters`);
462
+ }
463
+ if (raw?.theme) checkTheme(raw.theme, `${name}: theme`);
464
+ for (const [k, v] of Object.entries(raw?.tokens || {})) {
465
+ if (!TOKEN_RE.test(k)) {
466
+ errors.push(`${name}: "${k}" is not a --dm-* token`);
467
+ } else if (typeof v !== 'string' || !isSafeTokenValue(v)) {
468
+ errors.push(`${name}: value for ${k} is not a plain CSS value`);
469
+ }
470
+ }
471
+ if (raw?.css) {
472
+ if (typeof raw.css !== 'string' || raw.css.length > 20000) {
473
+ errors.push(`${name}: custom CSS is too long (20 KB max)`);
474
+ } else if (/@import|javascript:|expression\s*\(/i.test(raw.css)) {
475
+ errors.push(`${name}: custom CSS may not use @import, javascript: or expression()`);
476
+ }
477
+ }
478
+ if (!raw?.theme && !Object.keys(raw?.tokens || {}).length && !raw?.css) {
479
+ errors.push(`${name}: nothing to apply - pick a theme, set a token, or add CSS`);
480
+ }
481
+ });
482
+ }
483
+ return errors;
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Compilation
488
+ // ---------------------------------------------------------------------------
489
+
490
+ /**
491
+ * Compile the enabled overrides into one stylesheet.
492
+ *
493
+ * Emitted BEFORE content/custom.css so custom CSS stays the last word, and
494
+ * injected without a media query so it applies to print as well - a re-themed
495
+ * region that reverts on paper is a bug report waiting to happen.
496
+ *
497
+ * @param {object} cfg - A loaded theme config.
498
+ * @returns {string} CSS, or '' when there is nothing to emit.
499
+ */
500
+ export function compileOverrideCss(cfg) {
501
+ const overrides = (cfg?.overrides || []).filter((o) => o && o.enabled !== false && o.scope);
502
+ if (!overrides.length) return '';
503
+
504
+ const blocks = [];
505
+ for (const o of overrides) {
506
+ const decls = [];
507
+
508
+ if (o.theme) {
509
+ const themeDecls = getThemeDeclarations(o.theme);
510
+ if (themeDecls) decls.push(themeDecls);
511
+ }
512
+ for (const [k, v] of Object.entries(o.tokens || {})) {
513
+ if (TOKEN_RE.test(k) && typeof v === 'string' && isSafeTokenValue(v)) {
514
+ decls.push(`${k}: ${v};`);
515
+ }
516
+ }
517
+
518
+ if (decls.length) {
519
+ const label = o.label ? `/* ${String(o.label).replace(/[*/]/g, '')} */\n` : '';
520
+ blocks.push(`${label}${o.scope} {\n${decls.join('\n')}\n}`);
521
+ }
522
+ if (o.css && typeof o.css === 'string') {
523
+ blocks.push(o.css.replace(/<\/style>/gi, '<\\/style>'));
524
+ }
525
+ }
526
+ return blocks.join('\n\n');
527
+ }
528
+
529
+ /**
530
+ * The `<style>` tag for the page head, or '' when there are no overrides.
531
+ *
532
+ * @param {object} cfg
533
+ * @returns {string}
534
+ */
535
+ export function buildOverrideStyleTag(cfg) {
536
+ const css = compileOverrideCss(cfg);
537
+ if (!css.trim()) return '';
538
+ return `<style id="dm-theme-overrides">\n${css.replace(/<\/style>/gi, '<\\/style>')}\n</style>`;
539
+ }
540
+
541
+ export {THEME_FILE};
@@ -10,6 +10,7 @@ import {v4 as uuidv4} from 'uuid';
10
10
  import {config} from '../config.js';
11
11
  import {deleteProfile, ensureProfile} from './userProfiles.js';
12
12
  import * as cache from './cache/index.js';
13
+ import {hooks} from './hooks.js';
13
14
 
14
15
  const USERS_DIR = path.resolve(config.content.usersDir);
15
16
 
@@ -210,6 +211,13 @@ export async function deleteUser(id) {
210
211
  } catch {
211
212
  // Profile deletion is best-effort - never block user deletion
212
213
  }
214
+
215
+ // Plugins holding per-user data need to know, or it outlives the account.
216
+ // The Mail Reader is the pressing case: its records are stored mailbox
217
+ // credentials, and leaving those behind for a user who no longer exists is
218
+ // exactly the sort of thing an audit asks about. Fire-and-forget, after the
219
+ // deletion has actually happened, so a listener can never block it.
220
+ hooks.emit('user:deleted', {id});
213
221
  }
214
222
 
215
223
  /**
@@ -24,7 +24,7 @@
24
24
  <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
25
25
 
26
26
  <!-- Site CSS -->
27
- <link rel="stylesheet" href="/public/css/site.css?v=20260905-menu-highlight">
27
+ <link rel="stylesheet" href="/public/css/site.css?v=20260919-nav-hover-2">
28
28
  <link rel="stylesheet" href="/public/css/forms.css?v=20260821-wizard-steps">
29
29
  <!-- Menu item hover / current-page effects. Loaded after site.css so a
30
30
  menu's own highlight wins over the blanket link styling there. -->
@@ -114,6 +114,7 @@
114
114
  window.__CMS_NAV__ = {{navJson}};
115
115
  window.__CMS_SITE__ = {{siteJson}};
116
116
  {{footerScript}}
117
+ {{ctxMenusScript}}
117
118
  (function () {
118
119
  var c = window.__CMS_SITE__ && window.__CMS_SITE__.autoTheme;
119
120
  if (!c || !c.enabled) return;
@@ -127,7 +128,8 @@
127
128
 
128
129
  <!-- Site initialisation -->
129
130
  <script src="/public/js/site.js?v=20260829-motion-toggle" type="module"></script>
130
- <script src="/public/js/collection-context.js?v=20260819-export" type="module"></script>
131
+ <script src="/public/js/collection-context.js?v=20260919-ctx-tokens" type="module"></script>
132
+ {{ctxMenusModule}}
131
133
 
132
134
  <!-- Floating menu panels: collapse to a hamburger, drag to move, resize.
133
135
  Loaded here rather than from site.js so server-rendered overlays and
@@ -1,20 +0,0 @@
1
- {
2
- "contacts": [
3
- {
4
- "id": "97bbf711-84ce-41cc-898f-811936e051bc",
5
- "name": "Darryl Waterhouse",
6
- "email": "",
7
- "phone": "+447835449292",
8
- "groups": [
9
- "Coders"
10
- ],
11
- "notes": "Ledge",
12
- "favourite": true,
13
- "userId": "2421ad8e-060d-4548-8878-af7011d5e08b",
14
- "createdAt": "2026-03-24T16:43:39.907Z"
15
- }
16
- ],
17
- "groups": [
18
- "Coders"
19
- ]
20
- }
@@ -1 +0,0 @@
1
- []