domma-cms 0.71.0 → 0.73.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.
@@ -20,10 +20,12 @@ import {
20
20
  registerMenuLocation,
21
21
  registerSanitizeRules,
22
22
  registerShortcode,
23
+ hideSidebarUrl,
23
24
  registerSidebarItem,
24
25
  registerTransform
25
26
  } from './hooks.js';
26
27
  import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permissionRegistry.js';
28
+ import {registerThemeProvider} from './themeSettings.js';
27
29
  import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
28
30
  import {createCollection, getCollection} from './collections.js';
29
31
  import * as defaultRolesService from './roles.js';
@@ -138,6 +140,49 @@ const CORE_PLUGINS = new Set([]);
138
140
  /** Is this plugin a built-in feature rather than an optional add-on? */
139
141
  export function isCorePlugin(name) { return CORE_PLUGINS.has(name); }
140
142
 
143
+ /**
144
+ * The Tools promoted out of plugins/ in 0.67.0 - core code now, each with its
145
+ * own `#/<name>` screen and sidebar entry (server/server.js).
146
+ *
147
+ * Not plugins, so `supersedes` could never reach them the way it reaches the
148
+ * free Blog. A plugin that names one in `supersedes` TAKES IT OVER instead:
149
+ * the core Tool's sidebar entry is hidden and its screen hands over to the
150
+ * plugin's first admin route. The core Tool's API stays up - other plugins
151
+ * (Invoices reads contacts) depend on it, and the paid edition works over the
152
+ * same data - and, like supersession, nothing is written down: a lapsed
153
+ * licence means the plugin does not load and the core Tool is simply back.
154
+ */
155
+ export const BUILT_IN_TOOLS = new Set(['notes', 'todo', 'analytics', 'contacts']);
156
+
157
+ /** Built-in Tool → {plugin, route}, for the plugins that loaded this boot. */
158
+ const _toolTakeovers = {};
159
+
160
+ /**
161
+ * Which built-in Tools a loaded plugin has taken over.
162
+ *
163
+ * @returns {Object.<string, {plugin: string, route: string|null}>}
164
+ */
165
+ export function getToolTakeovers() {
166
+ return {..._toolTakeovers};
167
+ }
168
+
169
+ /**
170
+ * The takeovers one manifest claims: every built-in Tool in its `supersedes`
171
+ * that no earlier plugin has claimed. Pure, for the test.
172
+ *
173
+ * @param {object} manifest
174
+ * @param {object} [taken] - claims already made
175
+ * @returns {Object.<string, {plugin: string, route: string|null}>}
176
+ */
177
+ export function toolTakeoversOf(manifest, taken = {}) {
178
+ const out = {};
179
+ const route = manifest?.admin?.routes?.[0]?.path ?? null;
180
+ for (const tool of Array.isArray(manifest?.supersedes) ? manifest.supersedes : []) {
181
+ if (BUILT_IN_TOOLS.has(tool) && !taken[tool] && !out[tool]) out[tool] = {plugin: manifest.name, route};
182
+ }
183
+ return out;
184
+ }
185
+
141
186
  /**
142
187
  * Scan the plugins/ directory and return all valid manifests.
143
188
  * Validates mandatory fields and required files (plugin.js, config.js).
@@ -417,23 +462,37 @@ export async function registerPlugins(fastify) {
417
462
  await fastify.register(plugin, {
418
463
  prefix,
419
464
  auth: {authenticate, requireRole, requireAdmin, requireVisibility, requirePermission},
420
- hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation, registerSidebarItem, on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name)},
465
+ hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation,
466
+ // Stamped with the plugin's name, so the Menus editor can say where a Tool came from.
467
+ registerSidebarItem: (opts) => registerSidebarItem({...opts, item: {source: manifest.displayName || manifest.name, ...opts?.item}}),
468
+ // Custom themes the plugin offers (the Theme Roller) - bound to the
469
+ // plugin, so one plugin cannot replace another's. See themeSettings.js.
470
+ registerThemes: (provider) => registerThemeProvider(manifest.name, provider),
471
+ on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name)},
421
472
  settings,
422
473
  config: {}
423
474
  });
424
475
  loaded.push(manifest.name);
425
476
 
477
+ // A built-in Tool this plugin replaces: hide the core sidebar entry
478
+ // (the admin sends its screen to the plugin - getAdminPluginConfig).
479
+ for (const [tool, claim] of Object.entries(toolTakeoversOf(manifest, _toolTakeovers))) {
480
+ _toolTakeovers[tool] = claim;
481
+ hideSidebarUrl(`#/${tool}`);
482
+ fastify.log.info(`[plugins] built-in Tool "${tool}" taken over by "${manifest.name}".`);
483
+ }
484
+
426
485
  // Bridge plugin.json `admin.sidebar` entries into the sidebar
427
486
  // registry so they render in the admin sidebar. The admin renderer
428
487
  // reads plugin nav from /api/sidebar/registered-items; plugins
429
488
  // declare their nav in the manifest rather than calling
430
- // registerSidebarItem, so register it here on their behalf.
431
- // Tag each item with the plugin's core status so the admin sidebar
432
- // can auto-group it: core plugins → Tools folder, optional → Plugins.
489
+ // registerSidebarItem, so register it here on their behalf. An
490
+ // entry's `folder` is its default place (Tools when it names none);
491
+ // an admin can move it anywhere in the menu.
433
492
  const isCore = CORE_PLUGINS.has(manifest.name);
434
493
  for (const item of manifest.admin?.sidebar || []) {
435
494
  try {
436
- registerSidebarItem({item: {...item, core: isCore}});
495
+ registerSidebarItem({item: {source: manifest.displayName || manifest.name, ...item, core: isCore}});
437
496
  } catch (err) {
438
497
  fastify.log.warn(`[plugins] sidebar item for "${manifest.name}" skipped: ${err.message}`);
439
498
  }
@@ -1113,6 +1172,33 @@ export function pluginExpiry(manifest, given) {
1113
1172
  : null;
1114
1173
  }
1115
1174
 
1175
+
1176
+ /**
1177
+ * The sidebar name of the screen a view draws, for its banner - '' when the
1178
+ * plugin has one screen or the view has no sidebar entry of its own.
1179
+ *
1180
+ * A route maps a path to a view and a sidebar entry links to a path, so the
1181
+ * entry whose link is one of the view's routes names it. Sub-entries win over
1182
+ * the top-level one: Site Manager's top entry and its "Sites" entry share a
1183
+ * link, and "Sites" is the screen.
1184
+ *
1185
+ * @param {object} manifest
1186
+ * @param {string} viewName
1187
+ * @returns {string}
1188
+ */
1189
+ export function viewSection(manifest, viewName) {
1190
+ const admin = manifest.admin || {};
1191
+ const paths = new Set((admin.routes || [])
1192
+ .filter(r => r && r.view === viewName && typeof r.path === 'string')
1193
+ .map(r => `#${r.path}`));
1194
+ if (!paths.size) return '';
1195
+ const top = admin.sidebar || [];
1196
+ const entries = [...top.flatMap(e => (e && e.items) || []), ...top];
1197
+ const hit = entries.find(e => e && paths.has(e.url));
1198
+ const text = hit ? String(hit.text || '').trim() : '';
1199
+ return text && text !== (manifest.displayName || manifest.name) ? text : '';
1200
+ }
1201
+
1116
1202
  export async function getAdminPluginConfig() {
1117
1203
  const manifests = await discoverPlugins();
1118
1204
  const states = getPluginStates();
@@ -1160,6 +1246,7 @@ export async function getAdminPluginConfig() {
1160
1246
  meta[viewName] = {
1161
1247
  plugin: manifest.name,
1162
1248
  displayName: manifest.displayName || manifest.name,
1249
+ section: viewSection(manifest, viewName),
1163
1250
  version: manifest.version || '1.0.0',
1164
1251
  date: manifest.date || '',
1165
1252
  author: manifest.author || '',
@@ -1189,5 +1276,5 @@ export async function getAdminPluginConfig() {
1189
1276
  }
1190
1277
  }
1191
1278
 
1192
- return { sidebar, routes, views, css, meta };
1279
+ return { sidebar, routes, views, css, meta, takeovers: getToolTakeovers() };
1193
1280
  }
@@ -12,7 +12,7 @@ import {resolveLocation, resolveMenuDecorations, resolveOverlaysForPage} from '.
12
12
  import {buildMenuNav} from './menuRender.js';
13
13
  import {getProjectForPage} from './projects.js';
14
14
  import {resolveContextMenusForPage} from './contextMenus.js';
15
- import {buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig} from './themeSettings.js';
15
+ import {buildCustomThemeStyleTag, buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig, resolveThemeClasses} from './themeSettings.js';
16
16
  import {getSearchSettings} from './search.js';
17
17
 
18
18
  const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
@@ -74,7 +74,7 @@ function buildSearchAssets() {
74
74
  };
75
75
  }
76
76
 
77
- const THEME_SWITCHER_ASSET_V = '20260922-ts';
77
+ const THEME_SWITCHER_ASSET_V = '20260925-ts';
78
78
  const ANALYTICS_ASSET_V = '20260922-analytics';
79
79
 
80
80
  /**
@@ -397,7 +397,7 @@ export async function renderPage(page, opts = {}) {
397
397
  // Theme overrides land BEFORE content/custom.css: the overrides are
398
398
  // generated, custom.css is hand-written, and the hand-written one stays
399
399
  // the last word.
400
- headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
400
+ headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
401
401
  .filter(Boolean).join('\n'),
402
402
  bodyEndInject: [
403
403
  // Draft banner - first, so it is the topmost fixed element and a
@@ -789,16 +789,28 @@ async function buildThemeView(page = {}) {
789
789
 
790
790
  // A page pinned to a theme opts out of the automatic pair - otherwise the
791
791
  // pin would silently expire at the day/night boundary.
792
- const autoTheme = (!page.theme && cfg.autoTheme?.enabled) ? cfg.autoTheme : null;
792
+ // A copy: `apply` is added below, and the loaded config must not carry it.
793
+ const autoTheme = (!page.theme && cfg.autoTheme?.enabled) ? {...cfg.autoTheme} : null;
793
794
  const activeTheme = page.theme
794
795
  || (autoTheme ? autoTheme.dayTheme : cfg.theme)
795
796
  || 'charcoal-dark';
796
797
 
797
- // A custom (Theme Roller) theme is not a theme Domma's engine knows, so it
798
- // receives the built-in base and the custom class rides along separately,
799
- // added after init so _applyTheme() cannot strip it.
800
- const dommaTheme = cfg.baseTheme || activeTheme;
801
- const customThemeClass = cfg.baseTheme ? `dm-theme-${activeTheme}` : '';
798
+ // A custom theme (a plugin's - the Theme Roller) is not one Domma's engine
799
+ // knows: Domma is given its built-in base, and the custom class rides
800
+ // beside it. It is `dm-custom-<id>`, not `dm-theme-<id>`, because Domma
801
+ // strips every dm-theme-* class it did not put there.
802
+ const {dommaTheme, customClass: customThemeClass} = resolveThemeClasses(activeTheme, cfg);
803
+
804
+ // The browser switches between the day and night themes itself, so it is
805
+ // told how to apply each (base + custom class) rather than just its id.
806
+ if (autoTheme) {
807
+ const day = resolveThemeClasses(autoTheme.dayTheme, cfg);
808
+ const night = resolveThemeClasses(autoTheme.nightTheme, cfg);
809
+ autoTheme.apply = {
810
+ [autoTheme.dayTheme]: {theme: day.dommaTheme, custom: day.customClass},
811
+ [autoTheme.nightTheme]: {theme: night.dommaTheme, custom: night.customClass}
812
+ };
813
+ }
802
814
 
803
815
  const {fontLink, fontOverride} = buildFontVars(cfg.font?.family, cfg.font?.size);
804
816
 
@@ -810,6 +822,8 @@ async function buildThemeView(page = {}) {
810
822
  customThemeClass,
811
823
  fontLink,
812
824
  fontStyleTag: fontOverride ? `<style>${fontOverride}</style>` : '',
825
+ // Only the custom themes this page can show: its own, and day/night's.
826
+ customThemeStyleTag: buildCustomThemeStyleTag([activeTheme, autoTheme?.dayTheme, autoTheme?.nightTheme]),
813
827
  overrideStyleTag: buildOverrideStyleTag(cfg)
814
828
  };
815
829
  }
@@ -1008,7 +1022,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
1008
1022
  // Theme overrides land BEFORE content/custom.css: the overrides are
1009
1023
  // generated, custom.css is hand-written, and the hand-written one stays
1010
1024
  // the last word.
1011
- headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
1025
+ headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.customThemeStyleTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
1012
1026
  .filter(Boolean).join('\n'),
1013
1027
  bodyEndInject: [
1014
1028
  overlayMenus,
@@ -337,11 +337,22 @@ const ADMIN_PATH_RE = /^\/[A-Za-z0-9\-_/]*$/;
337
337
  * @param {*} raw
338
338
  * @returns {{home: string, allow: string[]}|null}
339
339
  */
340
- export function normaliseAdminScope(raw) {
341
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
342
- const clean = p => (typeof p === 'string' && ADMIN_PATH_RE.test(p.trim()))
340
+ /**
341
+ * An admin route path (`/portal`, `/plugins/x/dashboard`), cleaned - or null if
342
+ * it is not one. Trailing slashes go; the root stays `/`.
343
+ *
344
+ * @param {unknown} p
345
+ * @returns {string|null}
346
+ */
347
+ export function normaliseAdminPath(p) {
348
+ return (typeof p === 'string' && ADMIN_PATH_RE.test(p.trim()))
343
349
  ? (p.trim().replace(/\/+$/, '') || '/')
344
350
  : null;
351
+ }
352
+
353
+ export function normaliseAdminScope(raw) {
354
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
355
+ const clean = normaliseAdminPath;
345
356
  const allow = [...new Set((Array.isArray(raw.allow) ? raw.allow : []).map(clean).filter(Boolean))];
346
357
  const home = clean(raw.home) || allow[0] || null;
347
358
  if (!home) return null;
@@ -3,8 +3,13 @@
3
3
  *
4
4
  * Runs once on server boot. Idempotent - guarded by the existence of
5
5
  * config/menus/admin-sidebar.json. Seeds the admin sidebar menu with the
6
- * standard tree (Overview, Projects, Content, Data, System, Documentation)
7
- * and patches config/menu-locations.json to map the `admin-sidebar` slot.
6
+ * standard tree (Overview, Projects, Content, Data, Tools, System,
7
+ * Documentation) and patches config/menu-locations.json to map the
8
+ * `admin-sidebar` slot.
9
+ *
10
+ * Top-level folders carry a stable `key`. A Tool names its default folder by
11
+ * key, so an admin can rename a folder without the Tools that live in it
12
+ * falling out of it. ensureFolderKeys() brings menus seeded before keys up.
8
13
  */
9
14
  import fs from 'fs/promises';
10
15
  import path from 'path';
@@ -16,19 +21,19 @@ const DEFAULT_CONFIG_DIR = path.resolve(path.dirname(__filename), '..', '..', 'c
16
21
 
17
22
  const SEED_ITEMS = [
18
23
  {
19
- text: 'Overview', icon: 'home',
24
+ text: 'Overview', key: 'overview', icon: 'home',
20
25
  items: [
21
26
  {text: 'Dashboard', url: '#/', icon: 'home'}
22
27
  ]
23
28
  },
24
29
  {
25
- text: 'Projects', icon: 'folder', permission: 'projects',
30
+ text: 'Projects', key: 'projects', icon: 'folder', permission: 'projects',
26
31
  items: [
27
32
  {text: 'Manage projects', url: '#/projects', icon: 'folder', permission: 'projects'}
28
33
  ]
29
34
  },
30
35
  {
31
- text: 'Content', icon: 'edit',
36
+ text: 'Content', key: 'content', icon: 'edit',
32
37
  items: [
33
38
  {text: 'Pages', url: '#/pages', icon: 'file-text', permission: 'pages'},
34
39
  {text: 'Media', url: '#/media', icon: 'image', permission: 'media'},
@@ -36,7 +41,7 @@ const SEED_ITEMS = [
36
41
  ]
37
42
  },
38
43
  {
39
- text: 'Data', icon: 'database',
44
+ text: 'Data', key: 'data', icon: 'database',
40
45
  items: [
41
46
  {text: 'Collections', url: '#/collections', icon: 'database', permission: 'collections'},
42
47
  {text: 'Forms', url: '#/forms', icon: 'layout', permission: 'collections'},
@@ -47,8 +52,12 @@ const SEED_ITEMS = [
47
52
  {text: 'Components', url: '#/components', icon: 'component', permission: 'components'}
48
53
  ]
49
54
  },
55
+ // Enabled Tools are placed here at render time (placeTools in
56
+ // admin/js/lib/sidebar-grouping.js); the folder is persisted so an admin
57
+ // can rename, move or reorder it like any other.
58
+ {text: 'Tools', key: 'tools', icon: 'tool', items: []},
50
59
  {
51
- text: 'System', icon: 'settings',
60
+ text: 'System', key: 'system', icon: 'settings',
52
61
  items: [
53
62
  {text: 'Notifications', url: '#/system/notifications', icon: 'bell', permission: 'notifications'},
54
63
  {text: 'Roles', url: '#/roles', icon: 'shield', permission: 'plugins'},
@@ -63,7 +72,7 @@ const SEED_ITEMS = [
63
72
  ]
64
73
  },
65
74
  {
66
- text: 'Documentation', icon: 'book',
75
+ text: 'Documentation', key: 'documentation', icon: 'book',
67
76
  items: [
68
77
  DOC_USAGE_SUBMENU(),
69
78
  DOC_TUTORIALS_SUBMENU(),
@@ -307,3 +316,68 @@ export async function ensureDocumentationSubmenus(opts = {}) {
307
316
  console.log('[admin-sidebar] Upgraded Documentation group with per-topic submenus');
308
317
  return {updated: true};
309
318
  }
319
+
320
+ /** Keys for the folders the seed has always created, matched by their original name. */
321
+ const SEED_FOLDER_KEYS = {
322
+ overview: 'overview', projects: 'projects', content: 'content', data: 'data',
323
+ tools: 'tools', system: 'system', documentation: 'documentation'
324
+ };
325
+
326
+ /**
327
+ * Give an existing install's top-level folders their stable `key`, and - once -
328
+ * a persisted Tools folder for enabled Tools to be placed into.
329
+ *
330
+ * - A folder is keyed only if it has no key yet and still carries one of the
331
+ * seed's names; a folder the admin made, or renamed before keys existed, is
332
+ * left alone (placement falls back to Tools for it).
333
+ * - The Tools folder is added only on the first run (`meta.toolsFolder`), so
334
+ * an admin who later deletes it does not get it back on every boot. Until
335
+ * then placement synthesises one, exactly as before.
336
+ *
337
+ * Returns `{updated: boolean, reason?: string}`.
338
+ *
339
+ * @param {{configDir?: string}} [opts]
340
+ */
341
+ export async function ensureFolderKeys(opts = {}) {
342
+ const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
343
+ const menuPath = path.join(configDir, 'menus', 'admin-sidebar.json');
344
+
345
+ if (!await exists(menuPath)) {
346
+ return {updated: false, reason: 'admin-sidebar.json not present'};
347
+ }
348
+
349
+ const menu = await readJson(menuPath);
350
+ const items = Array.isArray(menu.items) ? menu.items : (menu.items = []);
351
+ const isFolder = (n) => n && typeof n === 'object' && !n.url && n.type !== 'separator' && n.type !== 'spacer';
352
+
353
+ let changed = false;
354
+ const used = new Set(items.filter(n => isFolder(n) && n.key).map(n => n.key));
355
+ for (const node of items) {
356
+ if (!isFolder(node) || node.key) continue;
357
+ const key = SEED_FOLDER_KEYS[String(node.text || '').trim().toLowerCase()];
358
+ if (!key || used.has(key)) continue;
359
+ node.key = key;
360
+ used.add(key);
361
+ changed = true;
362
+ }
363
+
364
+ // An empty menu is left empty: the renderer's minimal fallback (Dashboard,
365
+ // Menus) is what an admin needs to repair it, and a lone Tools folder would
366
+ // stop that fallback from applying.
367
+ if (!menu.meta?.toolsFolder && items.length) {
368
+ if (!used.has('tools')) {
369
+ const idx = items.findIndex(n => isFolder(n) && ['system', 'documentation'].includes(n.key));
370
+ const tools = {text: 'Tools', key: 'tools', icon: 'tool', items: []};
371
+ items.splice(idx === -1 ? items.length : idx, 0, tools);
372
+ }
373
+ menu.meta = {...(menu.meta || {}), toolsFolder: true};
374
+ changed = true;
375
+ }
376
+
377
+ if (!changed) return {updated: false, reason: 'already up to date'};
378
+
379
+ menu.meta = {...(menu.meta || {}), updatedAt: new Date().toISOString()};
380
+ await writeJson(menuPath, menu);
381
+ console.log('[admin-sidebar] Gave the sidebar folders stable keys');
382
+ return {updated: true};
383
+ }