domma-cms 0.54.2 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +151 -77
- 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/menu-editor.html +19 -25
- 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 +27 -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/menu-editor.js +13 -13
- 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 +11 -2
- package/bin/lib/smtp-defaults.js +53 -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/menu-highlight.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/menu-decor.mjs +1 -1
- 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/scripts/setup.js +8 -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/email.js +29 -3
- package/server/services/health.js +23 -2
- package/server/services/markdown.js +70 -9
- package/server/services/menuRender.js +28 -4
- package/server/services/menus.js +25 -1
- 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 +148 -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
|
@@ -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.
|
|
@@ -140,6 +180,9 @@ export async function renderPage(page, opts = {}) {
|
|
|
140
180
|
// the horizontal default, so __CMS_NAV__ stays unchanged for
|
|
141
181
|
// existing sites.
|
|
142
182
|
...(navMenu.orientation === 'vertical' && {orientation: 'vertical', side: navMenu.side === 'right' ? 'right' : 'left'}),
|
|
183
|
+
// Where the items sit along the bar. Like `highlight`, the navbar
|
|
184
|
+
// is drawn in the browser, so the value has to travel to site.js.
|
|
185
|
+
...(navMenu.align && {align: navMenu.align}),
|
|
143
186
|
...(navMenu.float && {float: navMenu.float}),
|
|
144
187
|
...(navMenu.appearOnHover && {appearOnHover: true}),
|
|
145
188
|
// Item highlighting: the navbar is built in the browser, so the
|
|
@@ -188,10 +231,9 @@ export async function renderPage(page, opts = {}) {
|
|
|
188
231
|
|
|
189
232
|
const breadcrumbsHtml = buildBreadcrumbsHtml(page, site);
|
|
190
233
|
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
: '';
|
|
234
|
+
const themeView = await buildThemeView(page);
|
|
235
|
+
const searchAssets = buildSearchAssets();
|
|
236
|
+
const {fontLink, fontStyleTag, autoTheme, activeTheme, dommaTheme, customThemeClass} = themeView;
|
|
195
237
|
|
|
196
238
|
const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
|
|
197
239
|
|
|
@@ -199,16 +241,6 @@ export async function renderPage(page, opts = {}) {
|
|
|
199
241
|
? `<style>${customCss.replace(/<\/style>/gi, '<\\/style>')}</style>`
|
|
200
242
|
: '';
|
|
201
243
|
|
|
202
|
-
const autoTheme = (!page.theme && site.autoTheme?.enabled) ? site.autoTheme : null;
|
|
203
|
-
const activeTheme = page.theme
|
|
204
|
-
|| (autoTheme ? autoTheme.dayTheme : site.theme)
|
|
205
|
-
|| 'charcoal-dark';
|
|
206
|
-
// When a custom theme is active, Domma's theme engine must receive the base
|
|
207
|
-
// built-in theme (which it recognises). The custom override class is added
|
|
208
|
-
// separately after Domma's init so _applyTheme() cannot strip it.
|
|
209
|
-
const dommaTheme = site.baseTheme || activeTheme;
|
|
210
|
-
const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
|
|
211
|
-
|
|
212
244
|
// Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
|
|
213
245
|
const footer = {
|
|
214
246
|
primary: footerPrimaryDecorated,
|
|
@@ -226,6 +258,26 @@ export async function renderPage(page, opts = {}) {
|
|
|
226
258
|
// counts for something nobody has published.
|
|
227
259
|
const noTrack = !!opts.preview || opts.noTrack === true;
|
|
228
260
|
|
|
261
|
+
/*
|
|
262
|
+
* Authored context menus. Resolved server-side so a visitor only receives
|
|
263
|
+
* the menus that apply to this page - a menu bound to /shop/* is not in the
|
|
264
|
+
* source of /about. Domma does the rest in the browser: which menu answers
|
|
265
|
+
* a given right-click is decided by walking outward from the click, not here.
|
|
266
|
+
*/
|
|
267
|
+
let ctxMenus = [];
|
|
268
|
+
try {
|
|
269
|
+
ctxMenus = await resolveContextMenusForPage(menuCtx);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
// A malformed menu file must not take the page down with it.
|
|
272
|
+
console.warn(`[context-menus] Resolution skipped: ${err.message}`);
|
|
273
|
+
}
|
|
274
|
+
const ctxMenusScript = ctxMenus.length
|
|
275
|
+
? `window.__CMS_CTXMENUS__ = ${JSON.stringify(ctxMenus).replace(/<\/script>/gi, '<\\/script>')};`
|
|
276
|
+
: '';
|
|
277
|
+
const ctxMenusModule = ctxMenus.length
|
|
278
|
+
? '<script src="/public/js/context-menus.js?v=20260918-ctx" type="module"></script>'
|
|
279
|
+
: '';
|
|
280
|
+
|
|
229
281
|
const vars = {
|
|
230
282
|
previewFlagScript: noTrack ? '<script>window.__CMS_PREVIEW__ = true;</script>' : '',
|
|
231
283
|
seoTitle,
|
|
@@ -249,6 +301,8 @@ export async function renderPage(page, opts = {}) {
|
|
|
249
301
|
showSidebar: preset.sidebar === true || page.sidebar === true,
|
|
250
302
|
navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
|
|
251
303
|
footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
|
|
304
|
+
ctxMenusScript: ctxMenusScript,
|
|
305
|
+
ctxMenusModule: ctxMenusModule,
|
|
252
306
|
siteJson: JSON.stringify(Object.assign(
|
|
253
307
|
{
|
|
254
308
|
title: site.title,
|
|
@@ -265,7 +319,11 @@ export async function renderPage(page, opts = {}) {
|
|
|
265
319
|
autoTheme ? {autoTheme} : {}
|
|
266
320
|
)).replace(/<\/script>/gi, '<\\/script>'),
|
|
267
321
|
headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
268
|
-
|
|
322
|
+
// Theme overrides land BEFORE content/custom.css: the overrides are
|
|
323
|
+
// generated, custom.css is hand-written, and the hand-written one stays
|
|
324
|
+
// the last word.
|
|
325
|
+
headInjectLate: [injection.headLate, searchAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
326
|
+
.filter(Boolean).join('\n'),
|
|
269
327
|
bodyEndInject: [
|
|
270
328
|
// Draft banner - first, so it is the topmost fixed element and a
|
|
271
329
|
// floating overlay menu cannot bury the only affordance telling
|
|
@@ -276,6 +334,7 @@ export async function renderPage(page, opts = {}) {
|
|
|
276
334
|
// content flow.
|
|
277
335
|
overlayMenus,
|
|
278
336
|
injection.bodyEnd,
|
|
337
|
+
searchAssets.bodyTag,
|
|
279
338
|
site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
|
|
280
339
|
site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : '',
|
|
281
340
|
(page.usedComponents || [])
|
|
@@ -638,6 +697,46 @@ function fontFamilyValue(family) {
|
|
|
638
697
|
return `'${safe}', ${generic}`;
|
|
639
698
|
}
|
|
640
699
|
|
|
700
|
+
/**
|
|
701
|
+
* The theme facts one render needs.
|
|
702
|
+
*
|
|
703
|
+
* config/theme.json is authoritative. The matching site.json keys still exist
|
|
704
|
+
* as a one-way mirror for plugins and the fleet manager, so reading them here
|
|
705
|
+
* would make the mirror load-bearing and give the CMS two sources of truth.
|
|
706
|
+
*
|
|
707
|
+
* @param {object} page - Page record; `page.theme` still wins over everything.
|
|
708
|
+
* @returns {Promise<object>} Template vars plus the compiled override stylesheet.
|
|
709
|
+
*/
|
|
710
|
+
async function buildThemeView(page = {}) {
|
|
711
|
+
const cfg = await loadThemeConfig();
|
|
712
|
+
|
|
713
|
+
// A page pinned to a theme opts out of the automatic pair - otherwise the
|
|
714
|
+
// pin would silently expire at the day/night boundary.
|
|
715
|
+
const autoTheme = (!page.theme && cfg.autoTheme?.enabled) ? cfg.autoTheme : null;
|
|
716
|
+
const activeTheme = page.theme
|
|
717
|
+
|| (autoTheme ? autoTheme.dayTheme : cfg.theme)
|
|
718
|
+
|| 'charcoal-dark';
|
|
719
|
+
|
|
720
|
+
// A custom (Theme Roller) theme is not a theme Domma's engine knows, so it
|
|
721
|
+
// receives the built-in base and the custom class rides along separately,
|
|
722
|
+
// added after init so _applyTheme() cannot strip it.
|
|
723
|
+
const dommaTheme = cfg.baseTheme || activeTheme;
|
|
724
|
+
const customThemeClass = cfg.baseTheme ? `dm-theme-${activeTheme}` : '';
|
|
725
|
+
|
|
726
|
+
const {fontLink, fontOverride} = buildFontVars(cfg.font?.family, cfg.font?.size);
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
cfg,
|
|
730
|
+
autoTheme,
|
|
731
|
+
activeTheme,
|
|
732
|
+
dommaTheme,
|
|
733
|
+
customThemeClass,
|
|
734
|
+
fontLink,
|
|
735
|
+
fontStyleTag: fontOverride ? `<style>${fontOverride}</style>` : '',
|
|
736
|
+
overrideStyleTag: buildOverrideStyleTag(cfg)
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
641
740
|
function buildFontVars(fontFamily, fontSize) {
|
|
642
741
|
const PRECONNECT = '<link rel="preconnect" href="https://fonts.googleapis.com">\n <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>';
|
|
643
742
|
const family = fontFamily || 'Roboto';
|
|
@@ -717,6 +816,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
717
816
|
// the horizontal default, so __CMS_NAV__ stays unchanged for
|
|
718
817
|
// existing sites.
|
|
719
818
|
...(navMenu.orientation === 'vertical' && {orientation: 'vertical', side: navMenu.side === 'right' ? 'right' : 'left'}),
|
|
819
|
+
...(navMenu.align && {align: navMenu.align}),
|
|
720
820
|
...(navMenu.appearOnHover && {appearOnHover: true}),
|
|
721
821
|
// Item highlighting: the navbar is built in the browser, so the
|
|
722
822
|
// config has to travel to site.js rather than being rendered here.
|
|
@@ -749,18 +849,17 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
749
849
|
};
|
|
750
850
|
const seoTags = buildSeoTags({page: syntheticPage, site, baseUrl, seoTitle, seoDescription, ogImage});
|
|
751
851
|
|
|
752
|
-
|
|
753
|
-
|
|
852
|
+
// Both render paths resolve the theme the same way. Wiring one and not the
|
|
853
|
+
// other is the standing trap in this file.
|
|
854
|
+
const themeView = await buildThemeView({});
|
|
855
|
+
const searchAssets = buildSearchAssets();
|
|
856
|
+
const {fontLink, fontStyleTag, activeTheme, dommaTheme, customThemeClass} = themeView;
|
|
754
857
|
const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
|
|
755
858
|
|
|
756
859
|
const customCssTag = customCss.trim()
|
|
757
860
|
? `<style>${customCss.replace(/<\/style>/gi, '<\\/style>')}</style>`
|
|
758
861
|
: '';
|
|
759
862
|
|
|
760
|
-
const activeTheme = site.theme || 'charcoal-dark';
|
|
761
|
-
const dommaTheme = site.baseTheme || activeTheme;
|
|
762
|
-
const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
|
|
763
|
-
|
|
764
863
|
// Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
|
|
765
864
|
const footer = {
|
|
766
865
|
primary: footerPrimaryDecorated,
|
|
@@ -772,6 +871,26 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
772
871
|
};
|
|
773
872
|
const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
|
|
774
873
|
|
|
874
|
+
/*
|
|
875
|
+
* Authored context menus. Resolved server-side so a visitor only receives
|
|
876
|
+
* the menus that apply to this page - a menu bound to /shop/* is not in the
|
|
877
|
+
* source of /about. Domma does the rest in the browser: which menu answers
|
|
878
|
+
* a given right-click is decided by walking outward from the click, not here.
|
|
879
|
+
*/
|
|
880
|
+
let ctxMenus = [];
|
|
881
|
+
try {
|
|
882
|
+
ctxMenus = await resolveContextMenusForPage({urlPath: seoMeta.urlPath || '/'});
|
|
883
|
+
} catch (err) {
|
|
884
|
+
// A malformed menu file must not take the page down with it.
|
|
885
|
+
console.warn(`[context-menus] Resolution skipped: ${err.message}`);
|
|
886
|
+
}
|
|
887
|
+
const ctxMenusScript = ctxMenus.length
|
|
888
|
+
? `window.__CMS_CTXMENUS__ = ${JSON.stringify(ctxMenus).replace(/<\/script>/gi, '<\\/script>')};`
|
|
889
|
+
: '';
|
|
890
|
+
const ctxMenusModule = ctxMenus.length
|
|
891
|
+
? '<script src="/public/js/context-menus.js?v=20260918-ctx" type="module"></script>'
|
|
892
|
+
: '';
|
|
893
|
+
|
|
775
894
|
const vars = {
|
|
776
895
|
seoTitle,
|
|
777
896
|
seoDescription,
|
|
@@ -795,6 +914,8 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
795
914
|
showSidebar: false,
|
|
796
915
|
navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
|
|
797
916
|
footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
|
|
917
|
+
ctxMenusScript: ctxMenusScript,
|
|
918
|
+
ctxMenusModule: ctxMenusModule,
|
|
798
919
|
siteJson: JSON.stringify({
|
|
799
920
|
title: site.title,
|
|
800
921
|
footer: site.footer
|
|
@@ -806,10 +927,15 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
806
927
|
social: site.social || null
|
|
807
928
|
}).replace(/<\/script>/gi, '<\\/script>'),
|
|
808
929
|
headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
809
|
-
|
|
930
|
+
// Theme overrides land BEFORE content/custom.css: the overrides are
|
|
931
|
+
// generated, custom.css is hand-written, and the hand-written one stays
|
|
932
|
+
// the last word.
|
|
933
|
+
headInjectLate: [injection.headLate, searchAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
934
|
+
.filter(Boolean).join('\n'),
|
|
810
935
|
bodyEndInject: [
|
|
811
936
|
overlayMenus,
|
|
812
937
|
injection.bodyEnd,
|
|
938
|
+
searchAssets.bodyTag,
|
|
813
939
|
(seoMeta.usedComponents || [])
|
|
814
940
|
.map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
|
|
815
941
|
.join('\n')
|
package/server/services/roles.js
CHANGED
|
@@ -74,7 +74,7 @@ const SEED_ENTRIES = [
|
|
|
74
74
|
'pages', 'media', 'blocks', 'components', 'navigation', 'menus',
|
|
75
75
|
'projects', 'layouts', 'collections', 'views', 'actions',
|
|
76
76
|
'users', 'settings', 'notifications', 'plugins',
|
|
77
|
-
'api-tokens', 'api-endpoints'
|
|
77
|
+
'api-tokens', 'api-endpoints', 'context-menus', 'theme'
|
|
78
78
|
],
|
|
79
79
|
badgeClass: 'badge-warning'
|
|
80
80
|
},
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search migration - one-shot lift of the retired `site-search` plugin.
|
|
3
|
+
*
|
|
4
|
+
* Search used to be a bundled plugin that the loader force-enabled as a core
|
|
5
|
+
* feature. It is now part of the CMS, which leaves three traces on an existing
|
|
6
|
+
* install:
|
|
7
|
+
*
|
|
8
|
+
* 1. its settings, stored inside config/plugins.json,
|
|
9
|
+
* 2. its plugins.json entry, which would otherwise offer a toggle for a
|
|
10
|
+
* feature that is no longer a plugin,
|
|
11
|
+
* 3. plugins/site-search/ itself - the updater replaces the plugins it ships
|
|
12
|
+
* and adds new ones, but never removes one that has gone away, so the old
|
|
13
|
+
* directory sits there and a click in the admin would run a SECOND copy of
|
|
14
|
+
* the search UI on every public page.
|
|
15
|
+
*
|
|
16
|
+
* Idempotent: with no plugins.json entry and no plugin directory there is
|
|
17
|
+
* nothing to do, which is the state of every fresh install.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import {normaliseSearchSettings} from './search.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run the migration.
|
|
25
|
+
*
|
|
26
|
+
* Paths are resolved per call rather than at import: the same process can run
|
|
27
|
+
* this against more than one install (the tests do), and a path frozen at load
|
|
28
|
+
* time would point at the first one forever.
|
|
29
|
+
*
|
|
30
|
+
* @returns {string[]} What it changed, for the startup log (empty = nothing to do)
|
|
31
|
+
*/
|
|
32
|
+
export function migrateSiteSearchPlugin() {
|
|
33
|
+
const CONFIG_DIR = path.resolve('config');
|
|
34
|
+
const SEARCH_CONFIG = path.join(CONFIG_DIR, 'search.json');
|
|
35
|
+
const PLUGINS_CONFIG = path.join(CONFIG_DIR, 'plugins.json');
|
|
36
|
+
const RETIRED_PLUGIN_DIR = path.resolve('plugins', 'site-search');
|
|
37
|
+
|
|
38
|
+
const changes = [];
|
|
39
|
+
let plugins = null;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
plugins = JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
// No plugins.json, or a malformed one. Neither is this migration's
|
|
45
|
+
// business - a broken file is reported loudly elsewhere.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const stored = plugins?.['site-search'];
|
|
49
|
+
|
|
50
|
+
// 1. Settings. Only ever written when the file is absent: a site that has
|
|
51
|
+
// already saved search settings has the newer answer.
|
|
52
|
+
if (!fs.existsSync(SEARCH_CONFIG)) {
|
|
53
|
+
const settings = normaliseSearchSettings(stored?.settings || {});
|
|
54
|
+
fs.writeFileSync(SEARCH_CONFIG, JSON.stringify(settings, null, 2) + '\n');
|
|
55
|
+
changes.push(stored ? 'lifted settings out of plugins.json' : 'wrote default settings');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. The plugins.json entry.
|
|
59
|
+
if (plugins && stored) {
|
|
60
|
+
delete plugins['site-search'];
|
|
61
|
+
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(plugins, null, 2) + '\n');
|
|
62
|
+
changes.push('removed the plugins.json entry');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. The plugin directory - but only if it is the bundled one. A directory
|
|
66
|
+
// of that name carrying anything else is somebody's own work, and this
|
|
67
|
+
// is no place to decide it should go.
|
|
68
|
+
if (fs.existsSync(RETIRED_PLUGIN_DIR)) {
|
|
69
|
+
let isBundled = false;
|
|
70
|
+
try {
|
|
71
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(RETIRED_PLUGIN_DIR, 'plugin.json'), 'utf8'));
|
|
72
|
+
isBundled = manifest.name === 'site-search' && manifest.author === 'Darryl Waterhouse';
|
|
73
|
+
} catch { /* no manifest - leave it alone */ }
|
|
74
|
+
|
|
75
|
+
if (isBundled) {
|
|
76
|
+
fs.rmSync(RETIRED_PLUGIN_DIR, {recursive: true, force: true});
|
|
77
|
+
changes.push('removed plugins/site-search/');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return changes;
|
|
82
|
+
}
|