domma-cms 0.55.1 → 0.69.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -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, getThemeTokenMap, listThemeIds, 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,117 @@ 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
+
77
+ const THEME_SWITCHER_ASSET_V = '20260922-ts';
78
+ const ANALYTICS_ASSET_V = '20260922-analytics';
79
+
80
+ /**
81
+ * The page-view beacon.
82
+ *
83
+ * Always emitted: analytics was a CORE_PLUGINS member, meaning it could not be
84
+ * switched off, and promotion should not quietly change that. What it counts is
85
+ * decided in the browser - Do-Not-Track, per-session dedup and preview renders
86
+ * all opt out there, where the facts are.
87
+ *
88
+ * Both render paths, the standing trap in this file.
89
+ *
90
+ * @returns {string} the body tag
91
+ */
92
+ function buildAnalyticsTag() {
93
+ return `<script src="/public/js/analytics.js?v=${ANALYTICS_ASSET_V}"></script>`;
94
+ }
95
+
96
+ /**
97
+ * The public theme switcher: stylesheet, theme list, script.
98
+ *
99
+ * The theme LIST is built here from listThemeIds() rather than being written
100
+ * out anywhere. The plugin this replaced shipped 26 hand-written dots against a
101
+ * stylesheet carrying 32 themes, and nothing made the gap visible - deriving it
102
+ * means a theme added to domma-themes.css simply appears.
103
+ *
104
+ * Each dot's colours are that theme's own --dm-primary and --dm-secondary, for
105
+ * the same reason: no second list of colours to drift.
106
+ *
107
+ * Both render paths need this, the same standing trap buildThemeView() and
108
+ * buildSearchAssets() carry.
109
+ *
110
+ * @returns {{headTag: string, bodyTag: string}} Empty strings when it is off
111
+ */
112
+ function buildThemeSwitcherAssets(themeCfg) {
113
+ const sw = themeCfg?.switcher;
114
+ if (!sw || sw.enabled !== true) return {headTag: '', bodyTag: ''};
115
+
116
+ let ids;
117
+ try {
118
+ ids = listThemeIds();
119
+ } catch {
120
+ return {headTag: '', bodyTag: ''};
121
+ }
122
+ const wanted = Array.isArray(sw.themes) && sw.themes.length
123
+ ? ids.filter(id => sw.themes.includes(id))
124
+ : ids;
125
+ if (!wanted.length) return {headTag: '', bodyTag: ''};
126
+
127
+ const themes = wanted.map(id => {
128
+ const tokens = getThemeTokenMap(id) || {};
129
+ return {
130
+ id,
131
+ label: id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
132
+ from: tokens['--dm-primary'] || tokens['--dm-surface'] || '#888',
133
+ to: tokens['--dm-secondary'] || tokens['--dm-background'] || '#888'
134
+ };
135
+ });
136
+
137
+ const payload = {
138
+ position: sw.position || 'right-center',
139
+ offsetX: sw.offsetX ?? 32,
140
+ offsetY: sw.offsetY ?? 32,
141
+ themes
142
+ };
143
+ const json = JSON.stringify(payload).replace(/<\/script>/gi, '<\\/script>');
144
+ return {
145
+ headTag: `<link rel="stylesheet" href="/public/css/theme-switcher.css?v=${THEME_SWITCHER_ASSET_V}">`,
146
+ bodyTag: `<script>window.__CMS_THEME_SWITCHER__ = ${json};</script>\n`
147
+ + `<script src="/public/js/theme-switcher.js?v=${THEME_SWITCHER_ASSET_V}"></script>`
148
+ };
149
+ }
150
+
37
151
  /**
38
152
  * Strip hidden items from the navigation config before public injection.
39
153
  * Returns a shallow clone - never mutates the cached config object.
@@ -191,10 +305,10 @@ export async function renderPage(page, opts = {}) {
191
305
 
192
306
  const breadcrumbsHtml = buildBreadcrumbsHtml(page, site);
193
307
 
194
- const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
195
- const fontStyleTag = fontOverride
196
- ? `<style>${fontOverride}</style>`
197
- : '';
308
+ const themeView = await buildThemeView(page);
309
+ const searchAssets = buildSearchAssets();
310
+ const switcherAssets = buildThemeSwitcherAssets(themeView.cfg);
311
+ const {fontLink, fontStyleTag, autoTheme, activeTheme, dommaTheme, customThemeClass} = themeView;
198
312
 
199
313
  const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
200
314
 
@@ -202,16 +316,6 @@ export async function renderPage(page, opts = {}) {
202
316
  ? `<style>${customCss.replace(/<\/style>/gi, '<\\/style>')}</style>`
203
317
  : '';
204
318
 
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
319
  // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
216
320
  const footer = {
217
321
  primary: footerPrimaryDecorated,
@@ -229,6 +333,26 @@ export async function renderPage(page, opts = {}) {
229
333
  // counts for something nobody has published.
230
334
  const noTrack = !!opts.preview || opts.noTrack === true;
231
335
 
336
+ /*
337
+ * Authored context menus. Resolved server-side so a visitor only receives
338
+ * the menus that apply to this page - a menu bound to /shop/* is not in the
339
+ * source of /about. Domma does the rest in the browser: which menu answers
340
+ * a given right-click is decided by walking outward from the click, not here.
341
+ */
342
+ let ctxMenus = [];
343
+ try {
344
+ ctxMenus = await resolveContextMenusForPage(menuCtx);
345
+ } catch (err) {
346
+ // A malformed menu file must not take the page down with it.
347
+ console.warn(`[context-menus] Resolution skipped: ${err.message}`);
348
+ }
349
+ const ctxMenusScript = ctxMenus.length
350
+ ? `window.__CMS_CTXMENUS__ = ${JSON.stringify(ctxMenus).replace(/<\/script>/gi, '<\\/script>')};`
351
+ : '';
352
+ const ctxMenusModule = ctxMenus.length
353
+ ? '<script src="/public/js/context-menus.js?v=20260918-ctx" type="module"></script>'
354
+ : '';
355
+
232
356
  const vars = {
233
357
  previewFlagScript: noTrack ? '<script>window.__CMS_PREVIEW__ = true;</script>' : '',
234
358
  seoTitle,
@@ -252,6 +376,8 @@ export async function renderPage(page, opts = {}) {
252
376
  showSidebar: preset.sidebar === true || page.sidebar === true,
253
377
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
254
378
  footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
379
+ ctxMenusScript: ctxMenusScript,
380
+ ctxMenusModule: ctxMenusModule,
255
381
  siteJson: JSON.stringify(Object.assign(
256
382
  {
257
383
  title: site.title,
@@ -268,7 +394,11 @@ export async function renderPage(page, opts = {}) {
268
394
  autoTheme ? {autoTheme} : {}
269
395
  )).replace(/<\/script>/gi, '<\\/script>'),
270
396
  headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
271
- headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
397
+ // Theme overrides land BEFORE content/custom.css: the overrides are
398
+ // generated, custom.css is hand-written, and the hand-written one stays
399
+ // the last word.
400
+ headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
401
+ .filter(Boolean).join('\n'),
272
402
  bodyEndInject: [
273
403
  // Draft banner - first, so it is the topmost fixed element and a
274
404
  // floating overlay menu cannot bury the only affordance telling
@@ -279,6 +409,9 @@ export async function renderPage(page, opts = {}) {
279
409
  // content flow.
280
410
  overlayMenus,
281
411
  injection.bodyEnd,
412
+ searchAssets.bodyTag,
413
+ switcherAssets.bodyTag,
414
+ buildAnalyticsTag(),
282
415
  site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
283
416
  site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : '',
284
417
  (page.usedComponents || [])
@@ -641,6 +774,46 @@ function fontFamilyValue(family) {
641
774
  return `'${safe}', ${generic}`;
642
775
  }
643
776
 
777
+ /**
778
+ * The theme facts one render needs.
779
+ *
780
+ * config/theme.json is authoritative. The matching site.json keys still exist
781
+ * as a one-way mirror for plugins and the fleet manager, so reading them here
782
+ * would make the mirror load-bearing and give the CMS two sources of truth.
783
+ *
784
+ * @param {object} page - Page record; `page.theme` still wins over everything.
785
+ * @returns {Promise<object>} Template vars plus the compiled override stylesheet.
786
+ */
787
+ async function buildThemeView(page = {}) {
788
+ const cfg = await loadThemeConfig();
789
+
790
+ // A page pinned to a theme opts out of the automatic pair - otherwise the
791
+ // pin would silently expire at the day/night boundary.
792
+ const autoTheme = (!page.theme && cfg.autoTheme?.enabled) ? cfg.autoTheme : null;
793
+ const activeTheme = page.theme
794
+ || (autoTheme ? autoTheme.dayTheme : cfg.theme)
795
+ || 'charcoal-dark';
796
+
797
+ // A custom (Theme Roller) theme is not a theme Domma's engine knows, so it
798
+ // receives the built-in base and the custom class rides along separately,
799
+ // added after init so _applyTheme() cannot strip it.
800
+ const dommaTheme = cfg.baseTheme || activeTheme;
801
+ const customThemeClass = cfg.baseTheme ? `dm-theme-${activeTheme}` : '';
802
+
803
+ const {fontLink, fontOverride} = buildFontVars(cfg.font?.family, cfg.font?.size);
804
+
805
+ return {
806
+ cfg,
807
+ autoTheme,
808
+ activeTheme,
809
+ dommaTheme,
810
+ customThemeClass,
811
+ fontLink,
812
+ fontStyleTag: fontOverride ? `<style>${fontOverride}</style>` : '',
813
+ overrideStyleTag: buildOverrideStyleTag(cfg)
814
+ };
815
+ }
816
+
644
817
  function buildFontVars(fontFamily, fontSize) {
645
818
  const PRECONNECT = '<link rel="preconnect" href="https://fonts.googleapis.com">\n <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>';
646
819
  const family = fontFamily || 'Roboto';
@@ -753,18 +926,18 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
753
926
  };
754
927
  const seoTags = buildSeoTags({page: syntheticPage, site, baseUrl, seoTitle, seoDescription, ogImage});
755
928
 
756
- const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
757
- const fontStyleTag = fontOverride ? `<style>${fontOverride}</style>` : '';
929
+ // Both render paths resolve the theme the same way. Wiring one and not the
930
+ // other is the standing trap in this file.
931
+ const themeView = await buildThemeView({});
932
+ const searchAssets = buildSearchAssets();
933
+ const switcherAssets = buildThemeSwitcherAssets(themeView.cfg);
934
+ const {fontLink, fontStyleTag, activeTheme, dommaTheme, customThemeClass} = themeView;
758
935
  const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
759
936
 
760
937
  const customCssTag = customCss.trim()
761
938
  ? `<style>${customCss.replace(/<\/style>/gi, '<\\/style>')}</style>`
762
939
  : '';
763
940
 
764
- const activeTheme = site.theme || 'charcoal-dark';
765
- const dommaTheme = site.baseTheme || activeTheme;
766
- const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
767
-
768
941
  // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
769
942
  const footer = {
770
943
  primary: footerPrimaryDecorated,
@@ -776,6 +949,26 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
776
949
  };
777
950
  const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
778
951
 
952
+ /*
953
+ * Authored context menus. Resolved server-side so a visitor only receives
954
+ * the menus that apply to this page - a menu bound to /shop/* is not in the
955
+ * source of /about. Domma does the rest in the browser: which menu answers
956
+ * a given right-click is decided by walking outward from the click, not here.
957
+ */
958
+ let ctxMenus = [];
959
+ try {
960
+ ctxMenus = await resolveContextMenusForPage({urlPath: seoMeta.urlPath || '/'});
961
+ } catch (err) {
962
+ // A malformed menu file must not take the page down with it.
963
+ console.warn(`[context-menus] Resolution skipped: ${err.message}`);
964
+ }
965
+ const ctxMenusScript = ctxMenus.length
966
+ ? `window.__CMS_CTXMENUS__ = ${JSON.stringify(ctxMenus).replace(/<\/script>/gi, '<\\/script>')};`
967
+ : '';
968
+ const ctxMenusModule = ctxMenus.length
969
+ ? '<script src="/public/js/context-menus.js?v=20260918-ctx" type="module"></script>'
970
+ : '';
971
+
779
972
  const vars = {
780
973
  seoTitle,
781
974
  seoDescription,
@@ -799,6 +992,8 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
799
992
  showSidebar: false,
800
993
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
801
994
  footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
995
+ ctxMenusScript: ctxMenusScript,
996
+ ctxMenusModule: ctxMenusModule,
802
997
  siteJson: JSON.stringify({
803
998
  title: site.title,
804
999
  footer: site.footer
@@ -810,10 +1005,17 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
810
1005
  social: site.social || null
811
1006
  }).replace(/<\/script>/gi, '<\\/script>'),
812
1007
  headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
813
- headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
1008
+ // Theme overrides land BEFORE content/custom.css: the overrides are
1009
+ // generated, custom.css is hand-written, and the hand-written one stays
1010
+ // the last word.
1011
+ headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
1012
+ .filter(Boolean).join('\n'),
814
1013
  bodyEndInject: [
815
1014
  overlayMenus,
816
1015
  injection.bodyEnd,
1016
+ searchAssets.bodyTag,
1017
+ switcherAssets.bodyTag,
1018
+ buildAnalyticsTag(),
817
1019
  (seoMeta.usedComponents || [])
818
1020
  .map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
819
1021
  .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
+ }