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.
- package/CLAUDE.md +194 -3
- package/README.md +18 -14
- package/admin/css/admin.css +1 -1
- package/admin/css/contacts.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +211 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +28 -24
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +6 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +27 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/server.json +3 -1
- package/package.json +14 -10
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4473 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1022 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -0
- package/plugins/blog/admin/templates/blog.html +35 -41
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +63 -40
- package/plugins/blog/admin/templates/settings.html +3 -9
- package/plugins/blog/admin/views/blog.js +288 -159
- package/plugins/blog/admin/views/categories.js +212 -206
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +306 -39
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -84
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +16 -13
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +259 -26
- package/plugins/blog/plugin.json +21 -73
- package/plugins/blog/plugin.public.js +217 -0
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +34 -0
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +77 -4
- package/scripts/copy-domma.js +48 -0
- package/scripts/run-tests.mjs +92 -0
- package/scripts/seed.js +1996 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/contacts.js +506 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/dashboard.js +8 -2
- package/server/routes/api/forms.js +42 -3
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +198 -9
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/api/todo.js +178 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +135 -1
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/collections.js +13 -3
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +124 -10
- package/server/services/permissionRegistry.js +74 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +340 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +224 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +580 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/analytics/admin/templates/analytics.html +0 -61
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -57
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -126
- package/plugins/contacts/admin/views/contacts.js +0 -710
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -71
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/contacts/plugin.js +0 -291
- package/plugins/contacts/plugin.json +0 -33
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/templates/editor.html +0 -129
- package/plugins/invoice/admin/templates/index.html +0 -43
- package/plugins/invoice/admin/templates/issuers.html +0 -5
- package/plugins/invoice/admin/templates/receivers.html +0 -5
- package/plugins/invoice/admin/views/editor.js +0 -267
- package/plugins/invoice/admin/views/index.js +0 -155
- package/plugins/invoice/admin/views/issuers.js +0 -23
- package/plugins/invoice/admin/views/party-view.js +0 -148
- package/plugins/invoice/admin/views/receivers.js +0 -22
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
- package/plugins/invoice/collections/invoices/schema.json +0 -27
- package/plugins/invoice/config.js +0 -16
- package/plugins/invoice/plugin.js +0 -283
- package/plugins/invoice/plugin.json +0 -85
- package/plugins/invoice/templates/invoice-print.html +0 -213
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/notes/plugin.json +0 -33
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/data/todos.json +0 -1
- package/plugins/todo/plugin.js +0 -102
- 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};
|
package/server/services/users.js
CHANGED
|
@@ -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=
|
|
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=
|
|
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>
|