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