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
@@ -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.
@@ -140,6 +180,9 @@ export async function renderPage(page, opts = {}) {
140
180
  // the horizontal default, so __CMS_NAV__ stays unchanged for
141
181
  // existing sites.
142
182
  ...(navMenu.orientation === 'vertical' && {orientation: 'vertical', side: navMenu.side === 'right' ? 'right' : 'left'}),
183
+ // Where the items sit along the bar. Like `highlight`, the navbar
184
+ // is drawn in the browser, so the value has to travel to site.js.
185
+ ...(navMenu.align && {align: navMenu.align}),
143
186
  ...(navMenu.float && {float: navMenu.float}),
144
187
  ...(navMenu.appearOnHover && {appearOnHover: true}),
145
188
  // Item highlighting: the navbar is built in the browser, so the
@@ -188,10 +231,9 @@ export async function renderPage(page, opts = {}) {
188
231
 
189
232
  const breadcrumbsHtml = buildBreadcrumbsHtml(page, site);
190
233
 
191
- const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
192
- const fontStyleTag = fontOverride
193
- ? `<style>${fontOverride}</style>`
194
- : '';
234
+ const themeView = await buildThemeView(page);
235
+ const searchAssets = buildSearchAssets();
236
+ const {fontLink, fontStyleTag, autoTheme, activeTheme, dommaTheme, customThemeClass} = themeView;
195
237
 
196
238
  const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
197
239
 
@@ -199,16 +241,6 @@ export async function renderPage(page, opts = {}) {
199
241
  ? `<style>${customCss.replace(/<\/style>/gi, '<\\/style>')}</style>`
200
242
  : '';
201
243
 
202
- const autoTheme = (!page.theme && site.autoTheme?.enabled) ? site.autoTheme : null;
203
- const activeTheme = page.theme
204
- || (autoTheme ? autoTheme.dayTheme : site.theme)
205
- || 'charcoal-dark';
206
- // When a custom theme is active, Domma's theme engine must receive the base
207
- // built-in theme (which it recognises). The custom override class is added
208
- // separately after Domma's init so _applyTheme() cannot strip it.
209
- const dommaTheme = site.baseTheme || activeTheme;
210
- const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
211
-
212
244
  // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
213
245
  const footer = {
214
246
  primary: footerPrimaryDecorated,
@@ -226,6 +258,26 @@ export async function renderPage(page, opts = {}) {
226
258
  // counts for something nobody has published.
227
259
  const noTrack = !!opts.preview || opts.noTrack === true;
228
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
+
229
281
  const vars = {
230
282
  previewFlagScript: noTrack ? '<script>window.__CMS_PREVIEW__ = true;</script>' : '',
231
283
  seoTitle,
@@ -249,6 +301,8 @@ export async function renderPage(page, opts = {}) {
249
301
  showSidebar: preset.sidebar === true || page.sidebar === true,
250
302
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
251
303
  footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
304
+ ctxMenusScript: ctxMenusScript,
305
+ ctxMenusModule: ctxMenusModule,
252
306
  siteJson: JSON.stringify(Object.assign(
253
307
  {
254
308
  title: site.title,
@@ -265,7 +319,11 @@ export async function renderPage(page, opts = {}) {
265
319
  autoTheme ? {autoTheme} : {}
266
320
  )).replace(/<\/script>/gi, '<\\/script>'),
267
321
  headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
268
- 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'),
269
327
  bodyEndInject: [
270
328
  // Draft banner - first, so it is the topmost fixed element and a
271
329
  // floating overlay menu cannot bury the only affordance telling
@@ -276,6 +334,7 @@ export async function renderPage(page, opts = {}) {
276
334
  // content flow.
277
335
  overlayMenus,
278
336
  injection.bodyEnd,
337
+ searchAssets.bodyTag,
279
338
  site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
280
339
  site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : '',
281
340
  (page.usedComponents || [])
@@ -638,6 +697,46 @@ function fontFamilyValue(family) {
638
697
  return `'${safe}', ${generic}`;
639
698
  }
640
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
+
641
740
  function buildFontVars(fontFamily, fontSize) {
642
741
  const PRECONNECT = '<link rel="preconnect" href="https://fonts.googleapis.com">\n <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>';
643
742
  const family = fontFamily || 'Roboto';
@@ -717,6 +816,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
717
816
  // the horizontal default, so __CMS_NAV__ stays unchanged for
718
817
  // existing sites.
719
818
  ...(navMenu.orientation === 'vertical' && {orientation: 'vertical', side: navMenu.side === 'right' ? 'right' : 'left'}),
819
+ ...(navMenu.align && {align: navMenu.align}),
720
820
  ...(navMenu.appearOnHover && {appearOnHover: true}),
721
821
  // Item highlighting: the navbar is built in the browser, so the
722
822
  // config has to travel to site.js rather than being rendered here.
@@ -749,18 +849,17 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
749
849
  };
750
850
  const seoTags = buildSeoTags({page: syntheticPage, site, baseUrl, seoTitle, seoDescription, ogImage});
751
851
 
752
- const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
753
- 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;
754
857
  const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
755
858
 
756
859
  const customCssTag = customCss.trim()
757
860
  ? `<style>${customCss.replace(/<\/style>/gi, '<\\/style>')}</style>`
758
861
  : '';
759
862
 
760
- const activeTheme = site.theme || 'charcoal-dark';
761
- const dommaTheme = site.baseTheme || activeTheme;
762
- const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
763
-
764
863
  // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
765
864
  const footer = {
766
865
  primary: footerPrimaryDecorated,
@@ -772,6 +871,26 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
772
871
  };
773
872
  const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
774
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
+
775
894
  const vars = {
776
895
  seoTitle,
777
896
  seoDescription,
@@ -795,6 +914,8 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
795
914
  showSidebar: false,
796
915
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
797
916
  footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
917
+ ctxMenusScript: ctxMenusScript,
918
+ ctxMenusModule: ctxMenusModule,
798
919
  siteJson: JSON.stringify({
799
920
  title: site.title,
800
921
  footer: site.footer
@@ -806,10 +927,15 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
806
927
  social: site.social || null
807
928
  }).replace(/<\/script>/gi, '<\\/script>'),
808
929
  headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
809
- 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'),
810
935
  bodyEndInject: [
811
936
  overlayMenus,
812
937
  injection.bodyEnd,
938
+ searchAssets.bodyTag,
813
939
  (seoMeta.usedComponents || [])
814
940
  .map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
815
941
  .join('\n')
@@ -74,7 +74,7 @@ const SEED_ENTRIES = [
74
74
  'pages', 'media', 'blocks', 'components', 'navigation', 'menus',
75
75
  'projects', 'layouts', 'collections', 'views', 'actions',
76
76
  'users', 'settings', 'notifications', 'plugins',
77
- 'api-tokens', 'api-endpoints'
77
+ 'api-tokens', 'api-endpoints', 'context-menus', 'theme'
78
78
  ],
79
79
  badgeClass: 'badge-warning'
80
80
  },
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Search migration - one-shot lift of the retired `site-search` plugin.
3
+ *
4
+ * Search used to be a bundled plugin that the loader force-enabled as a core
5
+ * feature. It is now part of the CMS, which leaves three traces on an existing
6
+ * install:
7
+ *
8
+ * 1. its settings, stored inside config/plugins.json,
9
+ * 2. its plugins.json entry, which would otherwise offer a toggle for a
10
+ * feature that is no longer a plugin,
11
+ * 3. plugins/site-search/ itself - the updater replaces the plugins it ships
12
+ * and adds new ones, but never removes one that has gone away, so the old
13
+ * directory sits there and a click in the admin would run a SECOND copy of
14
+ * the search UI on every public page.
15
+ *
16
+ * Idempotent: with no plugins.json entry and no plugin directory there is
17
+ * nothing to do, which is the state of every fresh install.
18
+ */
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import {normaliseSearchSettings} from './search.js';
22
+
23
+ /**
24
+ * Run the migration.
25
+ *
26
+ * Paths are resolved per call rather than at import: the same process can run
27
+ * this against more than one install (the tests do), and a path frozen at load
28
+ * time would point at the first one forever.
29
+ *
30
+ * @returns {string[]} What it changed, for the startup log (empty = nothing to do)
31
+ */
32
+ export function migrateSiteSearchPlugin() {
33
+ const CONFIG_DIR = path.resolve('config');
34
+ const SEARCH_CONFIG = path.join(CONFIG_DIR, 'search.json');
35
+ const PLUGINS_CONFIG = path.join(CONFIG_DIR, 'plugins.json');
36
+ const RETIRED_PLUGIN_DIR = path.resolve('plugins', 'site-search');
37
+
38
+ const changes = [];
39
+ let plugins = null;
40
+
41
+ try {
42
+ plugins = JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8'));
43
+ } catch {
44
+ // No plugins.json, or a malformed one. Neither is this migration's
45
+ // business - a broken file is reported loudly elsewhere.
46
+ }
47
+
48
+ const stored = plugins?.['site-search'];
49
+
50
+ // 1. Settings. Only ever written when the file is absent: a site that has
51
+ // already saved search settings has the newer answer.
52
+ if (!fs.existsSync(SEARCH_CONFIG)) {
53
+ const settings = normaliseSearchSettings(stored?.settings || {});
54
+ fs.writeFileSync(SEARCH_CONFIG, JSON.stringify(settings, null, 2) + '\n');
55
+ changes.push(stored ? 'lifted settings out of plugins.json' : 'wrote default settings');
56
+ }
57
+
58
+ // 2. The plugins.json entry.
59
+ if (plugins && stored) {
60
+ delete plugins['site-search'];
61
+ fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(plugins, null, 2) + '\n');
62
+ changes.push('removed the plugins.json entry');
63
+ }
64
+
65
+ // 3. The plugin directory - but only if it is the bundled one. A directory
66
+ // of that name carrying anything else is somebody's own work, and this
67
+ // is no place to decide it should go.
68
+ if (fs.existsSync(RETIRED_PLUGIN_DIR)) {
69
+ let isBundled = false;
70
+ try {
71
+ const manifest = JSON.parse(fs.readFileSync(path.join(RETIRED_PLUGIN_DIR, 'plugin.json'), 'utf8'));
72
+ isBundled = manifest.name === 'site-search' && manifest.author === 'Darryl Waterhouse';
73
+ } catch { /* no manifest - leave it alone */ }
74
+
75
+ if (isBundled) {
76
+ fs.rmSync(RETIRED_PLUGIN_DIR, {recursive: true, force: true});
77
+ changes.push('removed plugins/site-search/');
78
+ }
79
+ }
80
+
81
+ return changes;
82
+ }