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