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
@@ -7,8 +7,16 @@
7
7
  * valid names), paths are normalised and containment-checked after symlink
8
8
  * resolution, and only known text extensions are editable.
9
9
  *
10
- * Thrown errors carry `statusCode` (400 invalid, 404 missing, 413 too large)
11
- * for direct mapping in the route layer.
10
+ * A plugin marked `"closedSource": true` in its manifest is refused - licensed
11
+ * code is distributed to a site so it can RUN there, not so it can be read out
12
+ * of it, and the editor was handing over the server source in full. The one
13
+ * exception is a SUPER-ADMIN (role level 0), which on the fleet exists only on
14
+ * the manager: the people who publish the paid plugins are the people who need
15
+ * to read and edit them. Customer sites are all level 1, so nothing changes
16
+ * there - see the `allowLicensed` argument, set from the request's user.
17
+ *
18
+ * Thrown errors carry `statusCode` (400 invalid, 403 licensed, 404 missing,
19
+ * 413 too large) for direct mapping in the route layer.
12
20
  */
13
21
  import fs from 'fs/promises';
14
22
  import path from 'path';
@@ -40,7 +48,7 @@ function fail(statusCode, message) {
40
48
  * @param {string} name
41
49
  * @returns {Promise<string>} Absolute plugin directory
42
50
  */
43
- async function resolvePluginDir(name) {
51
+ async function resolvePluginDir(name, allowLicensed = false) {
44
52
  if (!NAME_PATTERN.test(String(name || ''))) throw fail(400, 'Invalid plugin name.');
45
53
  const dir = path.join(PLUGINS_DIR, name);
46
54
  try {
@@ -49,9 +57,42 @@ async function resolvePluginDir(name) {
49
57
  } catch {
50
58
  throw fail(404, `Plugin "${name}" not found.`);
51
59
  }
60
+
61
+ // Every operation in this module goes through here, which is why the check
62
+ // lives here and not in four places that could drift apart.
63
+ if (!allowLicensed && await isClosedSource(dir)) {
64
+ throw fail(403, `"${name}" is a licensed plugin. Its source is not editable here.`);
65
+ }
52
66
  return dir;
53
67
  }
54
68
 
69
+ /**
70
+ * Is this plugin's source off limits to the code editor?
71
+ *
72
+ * A licensed plugin is distributed to a site so it can RUN there, not so it
73
+ * can be read and copied - and the editor made it readable AND writable to
74
+ * anyone holding `plugins.develop`, which on a customer's own site is the
75
+ * customer. Writable is the worse half: the licence check is in that file.
76
+ *
77
+ * Declared by the plugin itself (`"closedSource": true` in plugin.json) so the
78
+ * flag travels with it wherever it is distributed, rather than depending on a
79
+ * list somewhere that a new paid plugin would have to be remembered into.
80
+ *
81
+ * A manifest that cannot be read is treated as closed. This gate failing open
82
+ * would publish the very thing it exists to protect.
83
+ *
84
+ * @param {string} dir - absolute plugin directory
85
+ * @returns {Promise<boolean>}
86
+ */
87
+ async function isClosedSource(dir) {
88
+ try {
89
+ const manifest = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
90
+ return manifest.closedSource === true;
91
+ } catch {
92
+ return true;
93
+ }
94
+ }
95
+
55
96
  /**
56
97
  * Resolve a relative file path inside the plugin dir, or throw.
57
98
  * Rejects absolute paths, traversal, hidden segments and unknown extensions.
@@ -98,8 +139,8 @@ async function resolveFilePath(pluginDir, relPath) {
98
139
  * @param {string} name
99
140
  * @returns {Promise<Array<{path: string, size: number, modified: string}>>}
100
141
  */
101
- export async function listPluginFiles(name) {
102
- const pluginDir = await resolvePluginDir(name);
142
+ export async function listPluginFiles(name, {allowLicensed = false} = {}) {
143
+ const pluginDir = await resolvePluginDir(name, allowLicensed);
103
144
  const files = [];
104
145
 
105
146
  async function walk(dir) {
@@ -132,8 +173,8 @@ export async function listPluginFiles(name) {
132
173
  * @param {string} relPath
133
174
  * @returns {Promise<{path: string, content: string}>}
134
175
  */
135
- export async function readPluginFile(name, relPath) {
136
- const pluginDir = await resolvePluginDir(name);
176
+ export async function readPluginFile(name, relPath, {allowLicensed = false} = {}) {
177
+ const pluginDir = await resolvePluginDir(name, allowLicensed);
137
178
  const abs = await resolveFilePath(pluginDir, relPath);
138
179
 
139
180
  let stat;
@@ -158,8 +199,8 @@ export async function readPluginFile(name, relPath) {
158
199
  * @param {string} content
159
200
  * @returns {Promise<{path: string, restartRequired: boolean}>}
160
201
  */
161
- export async function writePluginFile(name, relPath, content) {
162
- const pluginDir = await resolvePluginDir(name);
202
+ export async function writePluginFile(name, relPath, content, {allowLicensed = false} = {}) {
203
+ const pluginDir = await resolvePluginDir(name, allowLicensed);
163
204
  const abs = await resolveFilePath(pluginDir, relPath);
164
205
 
165
206
  if (typeof content !== 'string') throw fail(400, 'Content must be a string.');
@@ -198,8 +239,8 @@ export async function writePluginFile(name, relPath, content) {
198
239
  * @param {string} relPath
199
240
  * @returns {Promise<{path: string}>}
200
241
  */
201
- export async function deletePluginFile(name, relPath) {
202
- const pluginDir = await resolvePluginDir(name);
242
+ export async function deletePluginFile(name, relPath, {allowLicensed = false} = {}) {
243
+ const pluginDir = await resolvePluginDir(name, allowLicensed);
203
244
  const abs = await resolveFilePath(pluginDir, relPath);
204
245
  const rel = path.relative(pluginDir, abs).split(path.sep).join('/');
205
246
 
@@ -62,10 +62,24 @@ const _loadedPlugins = {};
62
62
 
63
63
  /**
64
64
  * Core plugins - always loaded regardless of plugins.json enabled state.
65
- * These are considered first-class CMS features, not optional add-ons.
65
+ * These are considered first-class CMS features, not optional add-ons: they
66
+ * ship with the CMS, cannot be switched off, and their sidebar entries file
67
+ * under Tools rather than Plugins.
68
+ *
69
+ * Being in here is the whole mechanism. `enabled` in plugins.json is ignored
70
+ * for these, so a site that had one switched off still gets it on the next
71
+ * start - which is the point of promoting one.
72
+ *
73
+ * It is also a waiting room rather than a destination: site-search sat here
74
+ * until search was moved into the CMS proper (server/services/search.js), and
75
+ * a feature nobody may switch off has no business being discovered, listed and
76
+ * toggled as though it were an add-on.
66
77
  */
67
78
  const CORE_PLUGINS = new Set(['analytics']);
68
79
 
80
+ /** Is this plugin a built-in feature rather than an optional add-on? */
81
+ export function isCorePlugin(name) { return CORE_PLUGINS.has(name); }
82
+
69
83
  /**
70
84
  * Scan the plugins/ directory and return all valid manifests.
71
85
  * Validates mandatory fields and required files (plugin.js, config.js).
@@ -187,6 +201,94 @@ export function savePluginState(name, state) {
187
201
  saveConfig('plugins', states);
188
202
  }
189
203
 
204
+ /**
205
+ * Work out which plugins are pushed aside by others that are loading.
206
+ *
207
+ * A plugin declares `"supersedes": ["other-plugin"]` in its manifest when it
208
+ * replaces another outright - the Pro edition of something that also ships
209
+ * free, for instance. The superseded plugin then registers nothing at all: no
210
+ * routes, no sidebar item, no collections, so the admin shows one of the pair
211
+ * rather than two that do the same job.
212
+ *
213
+ * The claim lives on the REPLACEMENT, never on the plugin being replaced, so
214
+ * a bundled plugin needs no knowledge of the paid one that may one day
215
+ * displace it.
216
+ *
217
+ * ## This is computed every boot and never written to config/plugins.json
218
+ *
219
+ * Persisting it would be a trap. Licences are withdrawn by disabling a plugin
220
+ * and leaving its files in place, so if superseding had written the free
221
+ * plugin off as disabled, a customer whose licence lapsed would lose the
222
+ * replacement AND the thing it replaced, ending up worse off than someone who
223
+ * never paid. Deriving it at load time means the superseded plugin simply
224
+ * comes back on the next restart.
225
+ *
226
+ * @param {object[]} manifests - every discovered manifest
227
+ * @param {Set<string>} activeNames - names that would otherwise load
228
+ * @param {{isCore?: (name: string) => boolean, warn?: (message: string) => void}} [options]
229
+ * @returns {Map<string, string>} superseded plugin name → the plugin displacing it
230
+ */
231
+ export function resolveSupersessions(manifests, activeNames, options = {}) {
232
+ const {isCore = () => false, warn = () => {}} = options;
233
+ const byName = new Map(manifests.map(m => [m.name, m]));
234
+
235
+ // Collect the claims worth honouring, dropping the nonsensical ones loudly
236
+ // rather than silently - a manifest that claims the wrong thing is a
237
+ // packaging mistake, and a silent no-op makes it very hard to find.
238
+ const claims = new Map();
239
+ for (const manifest of manifests) {
240
+ if (!activeNames.has(manifest.name)) continue;
241
+ const targets = Array.isArray(manifest.supersedes) ? manifest.supersedes : [];
242
+ const kept = [];
243
+
244
+ for (const target of targets) {
245
+ if (target === manifest.name) {
246
+ warn(`"${manifest.name}" lists itself in supersedes - ignored.`);
247
+ continue;
248
+ }
249
+ if (!byName.has(target) || !activeNames.has(target)) continue;
250
+ if (isCore(target)) {
251
+ warn(`"${manifest.name}" cannot supersede core plugin "${target}" - ignored.`);
252
+ continue;
253
+ }
254
+ const rival = byName.get(target);
255
+ if (Array.isArray(rival.supersedes) && rival.supersedes.includes(manifest.name)) {
256
+ warn(`"${manifest.name}" and "${target}" each claim to supersede the other - neither is applied.`);
257
+ continue;
258
+ }
259
+ kept.push(target);
260
+ }
261
+
262
+ if (kept.length) claims.set(manifest.name, kept);
263
+ }
264
+
265
+ if (!claims.size) return new Map();
266
+
267
+ // A superseded plugin's own claims must stop counting, or A>B>C would
268
+ // leave C displaced by a B that is not itself loading. Recompute from
269
+ // scratch each pass until the answer stops changing.
270
+ let superseded = new Map();
271
+ for (let pass = 0; pass <= manifests.length; pass++) {
272
+ const next = new Map();
273
+ for (const [claimant, targets] of claims) {
274
+ if (superseded.has(claimant)) continue;
275
+ for (const target of targets) {
276
+ if (!next.has(target)) next.set(target, claimant);
277
+ }
278
+ }
279
+
280
+ const settled = next.size === superseded.size
281
+ && [...next].every(([name, by]) => superseded.get(name) === by);
282
+ superseded = next;
283
+ if (settled) return superseded;
284
+ }
285
+
286
+ // Only reachable via a supersede cycle longer than a mutual pair. Refusing
287
+ // to apply any of it keeps the outcome deterministic and visible.
288
+ warn('supersedes declarations form a cycle - none are applied.');
289
+ return new Map();
290
+ }
291
+
190
292
  /**
191
293
  * Register server-side Fastify plugins for all enabled plugins.
192
294
  * Always loads plugin.js as the entry point.
@@ -198,11 +300,33 @@ export async function registerPlugins(fastify) {
198
300
  const manifests = await discoverPlugins();
199
301
  const states = getPluginStates();
200
302
 
303
+ // Which plugins would load on their own account, before anything displaces
304
+ // one another.
305
+ const activeNames = new Set(
306
+ manifests
307
+ .filter(m => states[m.name]?.enabled || CORE_PLUGINS.has(m.name))
308
+ .map(m => m.name)
309
+ );
310
+ const superseded = resolveSupersessions(manifests, activeNames, {
311
+ isCore: name => CORE_PLUGINS.has(name),
312
+ warn: message => fastify.log.warn(`[plugins] ${message}`)
313
+ });
314
+
201
315
  const loaded = [];
202
316
  for (const manifest of manifests) {
203
317
  const state = states[manifest.name] || {};
204
318
  if (!state.enabled && !CORE_PLUGINS.has(manifest.name)) continue;
205
319
 
320
+ const displacedBy = superseded.get(manifest.name);
321
+ if (displacedBy) {
322
+ // Recorded rather than merely skipped: the admin Plugins screen
323
+ // needs to say WHY this one is inactive, or the first thing anyone
324
+ // does is toggle it back on and watch nothing happen.
325
+ _loadedPlugins[manifest.name] = {enabled: false, publicEntry: null, supersededBy: displacedBy};
326
+ fastify.log.info(`[plugins] "${manifest.name}" superseded by "${displacedBy}" - not loaded.`);
327
+ continue;
328
+ }
329
+
206
330
  const entryPath = path.join(PLUGINS_DIR, manifest.name, 'plugin.js');
207
331
  try {
208
332
  const { default: plugin } = await import(entryPath);
@@ -613,6 +737,70 @@ export async function runLifecycleHook(name, hook, fastify) {
613
737
  *
614
738
  * @returns {Promise<{ sidebar: object[], routes: object[], views: object, css: object[] }>}
615
739
  */
740
+ /**
741
+ * The newest mtime anywhere under a directory of browser code.
742
+ *
743
+ * Browser code that is imported rather than routed has no version of its own
744
+ * - it is fetched under whatever token the importing entry carried. Folding
745
+ * the newest mtime of the whole tree into that token is what makes a change
746
+ * to an imported file actually reach a browser. It applies to the shared
747
+ * plugins/_lib/admin as much as to a plugin's own admin/lib, and missing
748
+ * either presents the same way: a fix that is on disk and not in the tab.
749
+ *
750
+ * @param {string} root absolute path; a missing directory is not an error
751
+ * @returns {Promise<number>} epoch milliseconds, or 0 if there is nothing there
752
+ */
753
+ async function newestAdminMtime(root) {
754
+ let newest = 0;
755
+
756
+ /**
757
+ * @param {string} dir
758
+ * @returns {Promise<void>}
759
+ */
760
+ async function walk(dir) {
761
+ let entries;
762
+ try {
763
+ entries = await fs.readdir(dir, {withFileTypes: true});
764
+ } catch {
765
+ return;
766
+ }
767
+ for (const entry of entries) {
768
+ const full = path.join(dir, entry.name);
769
+ if (entry.isDirectory()) {
770
+ await walk(full);
771
+ } else {
772
+ try {
773
+ newest = Math.max(newest, Math.floor((await fs.stat(full)).mtimeMs));
774
+ } catch { /* unreadable - ignore */ }
775
+ }
776
+ }
777
+ }
778
+
779
+ await walk(root);
780
+ return newest;
781
+ }
782
+
783
+ /**
784
+ * The licence line shown on a plugin's admin banner.
785
+ *
786
+ * A manifest may state it outright; otherwise it is derived, because every
787
+ * plugin needs a truthful answer here and almost none of them will ever carry
788
+ * the field. Closed source means it was bought, and the paid plugins are the
789
+ * ones already marked that way for the source-code guard - so the flag that
790
+ * protects the source also names the licence, rather than a second list
791
+ * someone has to remember to add to.
792
+ *
793
+ * @param {object} manifest
794
+ * @returns {string}
795
+ */
796
+ export function pluginLicence(manifest) {
797
+ const stated = manifest.licence || manifest.license;
798
+ if (typeof stated === 'string' && stated.trim()) return stated.trim();
799
+ if (CORE_PLUGINS.has(manifest.name)) return 'Built in';
800
+ if (manifest.closedSource) return 'Commercial licence';
801
+ return 'Included with Domma CMS';
802
+ }
803
+
616
804
  export async function getAdminPluginConfig() {
617
805
  const manifests = await discoverPlugins();
618
806
  const states = getPluginStates();
@@ -621,6 +809,10 @@ export async function getAdminPluginConfig() {
621
809
  const routes = [];
622
810
  const views = {};
623
811
  const css = [];
812
+ // Per-view plugin identity, so the admin can put the same banner above
813
+ // every plugin screen without each plugin having to render one (and each
814
+ // rendering it differently, which is what it did before).
815
+ const meta = {};
624
816
 
625
817
  for (const manifest of manifests) {
626
818
  const state = states[manifest.name] || {};
@@ -629,27 +821,58 @@ export async function getAdminPluginConfig() {
629
821
  if (manifest.admin.sidebar) sidebar.push(...manifest.admin.sidebar);
630
822
  if (manifest.admin.routes) routes.push(...manifest.admin.routes);
631
823
 
632
- // Auto-stamp view entries with mtime-based version (replaces manual ?v=N)
824
+ // Auto-stamp view entries with an mtime-based version.
825
+ //
826
+ // The stamp is the NEWEST mtime of the entry, the plugin's own admin
827
+ // tree and the shared admin library - not the entry alone. A view is
828
+ // usually a thin wrapper that rarely changes, while the code it
829
+ // imports changes constantly, so stamping only the entry left the
830
+ // browser fetching the same URL and serving the previous build of
831
+ // everything behind it. That presents as a feature that worked
832
+ // yesterday and does not today, on one machine.
833
+ const sharedStamp = Math.max(
834
+ await newestAdminMtime(path.join(PLUGINS_DIR, '_lib', 'admin')),
835
+ await newestAdminMtime(path.join(PLUGINS_DIR, manifest.name, 'admin'))
836
+ );
633
837
  for (const [viewName, viewDef] of Object.entries(manifest.admin.views || {})) {
634
838
  const entryBase = viewDef.entry.split('?')[0];
635
839
  let stamped = viewDef.entry;
636
840
  try {
637
841
  const filePath = path.join(PLUGINS_DIR, entryBase);
638
842
  const s = await fs.stat(filePath);
639
- stamped = `${entryBase}?v=${Math.floor(s.mtimeMs)}`;
843
+ stamped = `${entryBase}?v=${Math.max(Math.floor(s.mtimeMs), sharedStamp)}`;
640
844
  } catch { /* keep original entry as-is */ }
641
845
  views[viewName] = { ...viewDef, entry: stamped };
846
+ meta[viewName] = {
847
+ plugin: manifest.name,
848
+ displayName: manifest.displayName || manifest.name,
849
+ version: manifest.version || '1.0.0',
850
+ date: manifest.date || '',
851
+ author: manifest.author || '',
852
+ icon: manifest.icon || 'package',
853
+ core: CORE_PLUGINS.has(manifest.name),
854
+ licence: pluginLicence(manifest)
855
+ };
642
856
  }
643
857
 
644
- // Collect admin CSS links declared in plugin.json admin.css[]
858
+ // Collect admin CSS links declared in plugin.json admin.css[], stamped
859
+ // with the file's mtime for the same reason view entries are: a browser
860
+ // keys its cache on the full URL, so an unstamped stylesheet keeps
861
+ // serving the old rules after an update and the plugin looks broken in
862
+ // a way that only a hard refresh explains.
645
863
  for (let i = 0; i < (manifest.admin.css || []).length; i++) {
646
864
  const cssPath = manifest.admin.css[i];
865
+ let href = `/plugins/${manifest.name}/${cssPath}`;
866
+ try {
867
+ const stat = await fs.stat(path.join(PLUGINS_DIR, manifest.name, cssPath));
868
+ href += `?v=${Math.max(Math.floor(stat.mtimeMs), sharedStamp)}`;
869
+ } catch { /* unreadable - link it unstamped rather than not at all */ }
647
870
  css.push({
648
871
  id: `plugin-${manifest.name}-css${i > 0 ? `-${i}` : ''}`,
649
- href: `/plugins/${manifest.name}/${cssPath}`
872
+ href
650
873
  });
651
874
  }
652
875
  }
653
876
 
654
- return { sidebar, routes, views, css };
877
+ return { sidebar, routes, views, css, meta };
655
878
  }
@@ -11,6 +11,9 @@ import {applyTransforms} from './hooks.js';
11
11
  import {resolveLocation, resolveMenuDecorations, resolveOverlaysForPage} from './menus.js';
12
12
  import {buildMenuNav} from './menuRender.js';
13
13
  import {getProjectForPage} from './projects.js';
14
+ import {resolveContextMenusForPage} from './contextMenus.js';
15
+ import {buildOverrideStyleTag, loadThemeConfig} from './themeSettings.js';
16
+ import {getSearchSettings} from './search.js';
14
17
 
15
18
  const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
16
19
  const CUSTOM_CSS_PATH = new URL('../../content/custom.css', import.meta.url).pathname;
@@ -34,6 +37,43 @@ async function getTemplate() {
34
37
  }
35
38
  let _templateCache = null;
36
39
 
40
+ /*
41
+ * Asset version for the public search UI. Bumped by hand when search.js or
42
+ * search.css changes - the updater replaces public/ wholesale, and a browser
43
+ * holding the previous file otherwise keeps it.
44
+ */
45
+ const SEARCH_ASSET_V = '20260920-search';
46
+
47
+ /**
48
+ * The public search UI: stylesheet, settings, script.
49
+ *
50
+ * Settings travel INSIDE the page rather than being fetched, so the trigger is
51
+ * there on first paint instead of a round trip later. Every key is public by
52
+ * design (see SEARCH_DEFAULTS); nothing sensitive belongs in that object.
53
+ *
54
+ * Both render paths need this, and wiring one and not the other is the standing
55
+ * trap in this file - the same one buildThemeView() carries.
56
+ *
57
+ * @returns {{headTag: string, bodyTag: string}} Empty strings when search is off
58
+ */
59
+ function buildSearchAssets() {
60
+ let settings;
61
+ try {
62
+ settings = getSearchSettings();
63
+ } catch {
64
+ // A malformed config/search.json is not worth a blank page.
65
+ return {headTag: '', bodyTag: ''};
66
+ }
67
+ if (settings.enabled === false) return {headTag: '', bodyTag: ''};
68
+
69
+ const json = JSON.stringify(settings).replace(/<\/script>/gi, '<\\/script>');
70
+ return {
71
+ headTag: `<link rel="stylesheet" href="/public/css/search.css?v=${SEARCH_ASSET_V}">`,
72
+ bodyTag: `<script>window.__CMS_SEARCH__ = ${json};</script>\n`
73
+ + `<script src="/public/js/search.js?v=${SEARCH_ASSET_V}"></script>`
74
+ };
75
+ }
76
+
37
77
  /**
38
78
  * Strip hidden items from the navigation config before public injection.
39
79
  * Returns a shallow clone - never mutates the cached config object.
@@ -191,10 +231,9 @@ export async function renderPage(page, opts = {}) {
191
231
 
192
232
  const breadcrumbsHtml = buildBreadcrumbsHtml(page, site);
193
233
 
194
- const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
195
- const fontStyleTag = fontOverride
196
- ? `<style>${fontOverride}</style>`
197
- : '';
234
+ const themeView = await buildThemeView(page);
235
+ const searchAssets = buildSearchAssets();
236
+ const {fontLink, fontStyleTag, autoTheme, activeTheme, dommaTheme, customThemeClass} = themeView;
198
237
 
199
238
  const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
200
239
 
@@ -202,16 +241,6 @@ export async function renderPage(page, opts = {}) {
202
241
  ? `<style>${customCss.replace(/<\/style>/gi, '<\\/style>')}</style>`
203
242
  : '';
204
243
 
205
- const autoTheme = (!page.theme && site.autoTheme?.enabled) ? site.autoTheme : null;
206
- const activeTheme = page.theme
207
- || (autoTheme ? autoTheme.dayTheme : site.theme)
208
- || 'charcoal-dark';
209
- // When a custom theme is active, Domma's theme engine must receive the base
210
- // built-in theme (which it recognises). The custom override class is added
211
- // separately after Domma's init so _applyTheme() cannot strip it.
212
- const dommaTheme = site.baseTheme || activeTheme;
213
- const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
214
-
215
244
  // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
216
245
  const footer = {
217
246
  primary: footerPrimaryDecorated,
@@ -229,6 +258,26 @@ export async function renderPage(page, opts = {}) {
229
258
  // counts for something nobody has published.
230
259
  const noTrack = !!opts.preview || opts.noTrack === true;
231
260
 
261
+ /*
262
+ * Authored context menus. Resolved server-side so a visitor only receives
263
+ * the menus that apply to this page - a menu bound to /shop/* is not in the
264
+ * source of /about. Domma does the rest in the browser: which menu answers
265
+ * a given right-click is decided by walking outward from the click, not here.
266
+ */
267
+ let ctxMenus = [];
268
+ try {
269
+ ctxMenus = await resolveContextMenusForPage(menuCtx);
270
+ } catch (err) {
271
+ // A malformed menu file must not take the page down with it.
272
+ console.warn(`[context-menus] Resolution skipped: ${err.message}`);
273
+ }
274
+ const ctxMenusScript = ctxMenus.length
275
+ ? `window.__CMS_CTXMENUS__ = ${JSON.stringify(ctxMenus).replace(/<\/script>/gi, '<\\/script>')};`
276
+ : '';
277
+ const ctxMenusModule = ctxMenus.length
278
+ ? '<script src="/public/js/context-menus.js?v=20260918-ctx" type="module"></script>'
279
+ : '';
280
+
232
281
  const vars = {
233
282
  previewFlagScript: noTrack ? '<script>window.__CMS_PREVIEW__ = true;</script>' : '',
234
283
  seoTitle,
@@ -252,6 +301,8 @@ export async function renderPage(page, opts = {}) {
252
301
  showSidebar: preset.sidebar === true || page.sidebar === true,
253
302
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
254
303
  footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
304
+ ctxMenusScript: ctxMenusScript,
305
+ ctxMenusModule: ctxMenusModule,
255
306
  siteJson: JSON.stringify(Object.assign(
256
307
  {
257
308
  title: site.title,
@@ -268,7 +319,11 @@ export async function renderPage(page, opts = {}) {
268
319
  autoTheme ? {autoTheme} : {}
269
320
  )).replace(/<\/script>/gi, '<\\/script>'),
270
321
  headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
271
- headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
322
+ // Theme overrides land BEFORE content/custom.css: the overrides are
323
+ // generated, custom.css is hand-written, and the hand-written one stays
324
+ // the last word.
325
+ headInjectLate: [injection.headLate, searchAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
326
+ .filter(Boolean).join('\n'),
272
327
  bodyEndInject: [
273
328
  // Draft banner - first, so it is the topmost fixed element and a
274
329
  // floating overlay menu cannot bury the only affordance telling
@@ -279,6 +334,7 @@ export async function renderPage(page, opts = {}) {
279
334
  // content flow.
280
335
  overlayMenus,
281
336
  injection.bodyEnd,
337
+ searchAssets.bodyTag,
282
338
  site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
283
339
  site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : '',
284
340
  (page.usedComponents || [])
@@ -641,6 +697,46 @@ function fontFamilyValue(family) {
641
697
  return `'${safe}', ${generic}`;
642
698
  }
643
699
 
700
+ /**
701
+ * The theme facts one render needs.
702
+ *
703
+ * config/theme.json is authoritative. The matching site.json keys still exist
704
+ * as a one-way mirror for plugins and the fleet manager, so reading them here
705
+ * would make the mirror load-bearing and give the CMS two sources of truth.
706
+ *
707
+ * @param {object} page - Page record; `page.theme` still wins over everything.
708
+ * @returns {Promise<object>} Template vars plus the compiled override stylesheet.
709
+ */
710
+ async function buildThemeView(page = {}) {
711
+ const cfg = await loadThemeConfig();
712
+
713
+ // A page pinned to a theme opts out of the automatic pair - otherwise the
714
+ // pin would silently expire at the day/night boundary.
715
+ const autoTheme = (!page.theme && cfg.autoTheme?.enabled) ? cfg.autoTheme : null;
716
+ const activeTheme = page.theme
717
+ || (autoTheme ? autoTheme.dayTheme : cfg.theme)
718
+ || 'charcoal-dark';
719
+
720
+ // A custom (Theme Roller) theme is not a theme Domma's engine knows, so it
721
+ // receives the built-in base and the custom class rides along separately,
722
+ // added after init so _applyTheme() cannot strip it.
723
+ const dommaTheme = cfg.baseTheme || activeTheme;
724
+ const customThemeClass = cfg.baseTheme ? `dm-theme-${activeTheme}` : '';
725
+
726
+ const {fontLink, fontOverride} = buildFontVars(cfg.font?.family, cfg.font?.size);
727
+
728
+ return {
729
+ cfg,
730
+ autoTheme,
731
+ activeTheme,
732
+ dommaTheme,
733
+ customThemeClass,
734
+ fontLink,
735
+ fontStyleTag: fontOverride ? `<style>${fontOverride}</style>` : '',
736
+ overrideStyleTag: buildOverrideStyleTag(cfg)
737
+ };
738
+ }
739
+
644
740
  function buildFontVars(fontFamily, fontSize) {
645
741
  const PRECONNECT = '<link rel="preconnect" href="https://fonts.googleapis.com">\n <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>';
646
742
  const family = fontFamily || 'Roboto';
@@ -753,18 +849,17 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
753
849
  };
754
850
  const seoTags = buildSeoTags({page: syntheticPage, site, baseUrl, seoTitle, seoDescription, ogImage});
755
851
 
756
- const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
757
- const fontStyleTag = fontOverride ? `<style>${fontOverride}</style>` : '';
852
+ // Both render paths resolve the theme the same way. Wiring one and not the
853
+ // other is the standing trap in this file.
854
+ const themeView = await buildThemeView({});
855
+ const searchAssets = buildSearchAssets();
856
+ const {fontLink, fontStyleTag, activeTheme, dommaTheme, customThemeClass} = themeView;
758
857
  const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
759
858
 
760
859
  const customCssTag = customCss.trim()
761
860
  ? `<style>${customCss.replace(/<\/style>/gi, '<\\/style>')}</style>`
762
861
  : '';
763
862
 
764
- const activeTheme = site.theme || 'charcoal-dark';
765
- const dommaTheme = site.baseTheme || activeTheme;
766
- const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
767
-
768
863
  // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
769
864
  const footer = {
770
865
  primary: footerPrimaryDecorated,
@@ -776,6 +871,26 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
776
871
  };
777
872
  const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
778
873
 
874
+ /*
875
+ * Authored context menus. Resolved server-side so a visitor only receives
876
+ * the menus that apply to this page - a menu bound to /shop/* is not in the
877
+ * source of /about. Domma does the rest in the browser: which menu answers
878
+ * a given right-click is decided by walking outward from the click, not here.
879
+ */
880
+ let ctxMenus = [];
881
+ try {
882
+ ctxMenus = await resolveContextMenusForPage({urlPath: seoMeta.urlPath || '/'});
883
+ } catch (err) {
884
+ // A malformed menu file must not take the page down with it.
885
+ console.warn(`[context-menus] Resolution skipped: ${err.message}`);
886
+ }
887
+ const ctxMenusScript = ctxMenus.length
888
+ ? `window.__CMS_CTXMENUS__ = ${JSON.stringify(ctxMenus).replace(/<\/script>/gi, '<\\/script>')};`
889
+ : '';
890
+ const ctxMenusModule = ctxMenus.length
891
+ ? '<script src="/public/js/context-menus.js?v=20260918-ctx" type="module"></script>'
892
+ : '';
893
+
779
894
  const vars = {
780
895
  seoTitle,
781
896
  seoDescription,
@@ -799,6 +914,8 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
799
914
  showSidebar: false,
800
915
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
801
916
  footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
917
+ ctxMenusScript: ctxMenusScript,
918
+ ctxMenusModule: ctxMenusModule,
802
919
  siteJson: JSON.stringify({
803
920
  title: site.title,
804
921
  footer: site.footer
@@ -810,10 +927,15 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
810
927
  social: site.social || null
811
928
  }).replace(/<\/script>/gi, '<\\/script>'),
812
929
  headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
813
- headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
930
+ // Theme overrides land BEFORE content/custom.css: the overrides are
931
+ // generated, custom.css is hand-written, and the hand-written one stays
932
+ // the last word.
933
+ headInjectLate: [injection.headLate, searchAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
934
+ .filter(Boolean).join('\n'),
814
935
  bodyEndInject: [
815
936
  overlayMenus,
816
937
  injection.bodyEnd,
938
+ searchAssets.bodyTag,
817
939
  (seoMeta.usedComponents || [])
818
940
  .map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
819
941
  .join('\n')