domma-cms 0.70.1 → 0.72.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.
Files changed (48) hide show
  1. package/CLAUDE.md +24 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/app.js +5 -5
  6. package/admin/js/lib/activity-throbber.js +1 -0
  7. package/admin/js/lib/admin-scope.js +1 -0
  8. package/admin/js/lib/menu-rows.js +1 -0
  9. package/admin/js/lib/plugin-chrome.js +1 -1
  10. package/admin/js/lib/sidebar-grouping.js +1 -1
  11. package/admin/js/lib/sidebar-renderer.js +4 -4
  12. package/admin/js/templates/page-editor.html +1 -4
  13. package/admin/js/templates/role-editor.html +28 -0
  14. package/admin/js/templates/settings.html +8 -0
  15. package/admin/js/views/menu-editor.js +16 -16
  16. package/admin/js/views/page-editor.js +26 -26
  17. package/admin/js/views/role-editor.js +3 -1
  18. package/admin/js/views/settings.js +3 -3
  19. package/package.json +2 -2
  20. package/plugins/_template/plugin.js +5 -2
  21. package/plugins/blog/CLAUDE.md +20 -5
  22. package/plugins/blog/admin/views/kit.js +8 -10
  23. package/plugins/blog/lib/layouts.js +36 -0
  24. package/plugins/blog/lib/nav-link.js +22 -0
  25. package/plugins/blog/lib/page.js +13 -1
  26. package/plugins/blog/plugin.js +28 -9
  27. package/plugins/blog/plugin.json +6 -6
  28. package/plugins/blog/plugin.public.js +102 -80
  29. package/plugins/blog/tests/layouts.test.js +29 -0
  30. package/plugins/blog/tests/nav-link.test.js +10 -1
  31. package/plugins/blog/tests/public.test.js +21 -0
  32. package/plugins/free-tier.lock.json +13 -13
  33. package/server/middleware/auth.js +21 -0
  34. package/server/routes/api/auth.js +29 -8
  35. package/server/routes/api/collections.js +40 -3
  36. package/server/routes/api/settings.js +9 -0
  37. package/server/routes/api/sidebar.js +31 -10
  38. package/server/routes/public.js +13 -17
  39. package/server/server.js +10 -8
  40. package/server/services/adminHome.js +87 -0
  41. package/server/services/hooks.js +30 -6
  42. package/server/services/menus.js +36 -6
  43. package/server/services/permissionRegistry.js +10 -7
  44. package/server/services/pluginInstaller.js +23 -9
  45. package/server/services/plugins.js +333 -45
  46. package/server/services/roles.js +181 -5
  47. package/server/services/sidebar-migration.js +82 -8
  48. package/admin/js/lib/sidebar-grouping.test.js +0 -1
@@ -14,18 +14,22 @@ import fs from 'fs/promises';
14
14
  import path from 'path';
15
15
  import matter from 'gray-matter';
16
16
  import {getConfig, saveConfig} from '../config.js';
17
- import {authenticate, requireAdmin, requireRole, requireVisibility} from '../middleware/auth.js';
17
+ import {authenticate, requireAdmin, requirePermission, requireRole, requireVisibility} from '../middleware/auth.js';
18
18
  import {
19
19
  hooks,
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';
27
28
  import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
28
29
  import {createCollection, getCollection} from './collections.js';
30
+ import * as defaultRolesService from './roles.js';
31
+ import {normaliseAdminScope} from './roles.js';
32
+ import * as defaultUsersService from './users.js';
29
33
 
30
34
  const PLUGINS_DIR = path.resolve('plugins');
31
35
 
@@ -135,6 +139,49 @@ const CORE_PLUGINS = new Set([]);
135
139
  /** Is this plugin a built-in feature rather than an optional add-on? */
136
140
  export function isCorePlugin(name) { return CORE_PLUGINS.has(name); }
137
141
 
142
+ /**
143
+ * The Tools promoted out of plugins/ in 0.67.0 - core code now, each with its
144
+ * own `#/<name>` screen and sidebar entry (server/server.js).
145
+ *
146
+ * Not plugins, so `supersedes` could never reach them the way it reaches the
147
+ * free Blog. A plugin that names one in `supersedes` TAKES IT OVER instead:
148
+ * the core Tool's sidebar entry is hidden and its screen hands over to the
149
+ * plugin's first admin route. The core Tool's API stays up - other plugins
150
+ * (Invoices reads contacts) depend on it, and the paid edition works over the
151
+ * same data - and, like supersession, nothing is written down: a lapsed
152
+ * licence means the plugin does not load and the core Tool is simply back.
153
+ */
154
+ export const BUILT_IN_TOOLS = new Set(['notes', 'todo', 'analytics', 'contacts']);
155
+
156
+ /** Built-in Tool → {plugin, route}, for the plugins that loaded this boot. */
157
+ const _toolTakeovers = {};
158
+
159
+ /**
160
+ * Which built-in Tools a loaded plugin has taken over.
161
+ *
162
+ * @returns {Object.<string, {plugin: string, route: string|null}>}
163
+ */
164
+ export function getToolTakeovers() {
165
+ return {..._toolTakeovers};
166
+ }
167
+
168
+ /**
169
+ * The takeovers one manifest claims: every built-in Tool in its `supersedes`
170
+ * that no earlier plugin has claimed. Pure, for the test.
171
+ *
172
+ * @param {object} manifest
173
+ * @param {object} [taken] - claims already made
174
+ * @returns {Object.<string, {plugin: string, route: string|null}>}
175
+ */
176
+ export function toolTakeoversOf(manifest, taken = {}) {
177
+ const out = {};
178
+ const route = manifest?.admin?.routes?.[0]?.path ?? null;
179
+ for (const tool of Array.isArray(manifest?.supersedes) ? manifest.supersedes : []) {
180
+ if (BUILT_IN_TOOLS.has(tool) && !taken[tool] && !out[tool]) out[tool] = {plugin: manifest.name, route};
181
+ }
182
+ return out;
183
+ }
184
+
138
185
  /**
139
186
  * Scan the plugins/ directory and return all valid manifests.
140
187
  * Validates mandatory fields and required files (plugin.js, config.js).
@@ -413,24 +460,35 @@ export async function registerPlugins(fastify) {
413
460
  const prefix = `/api/plugins/${manifest.name}`;
414
461
  await fastify.register(plugin, {
415
462
  prefix,
416
- auth: {authenticate, requireRole, requireAdmin, requireVisibility},
417
- hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation, registerSidebarItem, on: hooks.on.bind(hooks)},
463
+ auth: {authenticate, requireRole, requireAdmin, requireVisibility, requirePermission},
464
+ hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation,
465
+ // Stamped with the plugin's name, so the Menus editor can say where a Tool came from.
466
+ registerSidebarItem: (opts) => registerSidebarItem({...opts, item: {source: manifest.displayName || manifest.name, ...opts?.item}}),
467
+ on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name)},
418
468
  settings,
419
469
  config: {}
420
470
  });
421
471
  loaded.push(manifest.name);
422
472
 
473
+ // A built-in Tool this plugin replaces: hide the core sidebar entry
474
+ // (the admin sends its screen to the plugin - getAdminPluginConfig).
475
+ for (const [tool, claim] of Object.entries(toolTakeoversOf(manifest, _toolTakeovers))) {
476
+ _toolTakeovers[tool] = claim;
477
+ hideSidebarUrl(`#/${tool}`);
478
+ fastify.log.info(`[plugins] built-in Tool "${tool}" taken over by "${manifest.name}".`);
479
+ }
480
+
423
481
  // Bridge plugin.json `admin.sidebar` entries into the sidebar
424
482
  // registry so they render in the admin sidebar. The admin renderer
425
483
  // reads plugin nav from /api/sidebar/registered-items; plugins
426
484
  // declare their nav in the manifest rather than calling
427
- // registerSidebarItem, so register it here on their behalf.
428
- // Tag each item with the plugin's core status so the admin sidebar
429
- // can auto-group it: core plugins → Tools folder, optional → Plugins.
485
+ // registerSidebarItem, so register it here on their behalf. An
486
+ // entry's `folder` is its default place (Tools when it names none);
487
+ // an admin can move it anywhere in the menu.
430
488
  const isCore = CORE_PLUGINS.has(manifest.name);
431
489
  for (const item of manifest.admin?.sidebar || []) {
432
490
  try {
433
- registerSidebarItem({item: {...item, core: isCore}});
491
+ registerSidebarItem({item: {source: manifest.displayName || manifest.name, ...item, core: isCore}});
434
492
  } catch (err) {
435
493
  fastify.log.warn(`[plugins] sidebar item for "${manifest.name}" skipped: ${err.message}`);
436
494
  }
@@ -440,6 +498,12 @@ export async function registerPlugins(fastify) {
440
498
  await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
441
499
  .catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
442
500
 
501
+ // Permissions are held in memory, so they are registered on every
502
+ // boot, not only on enable (roles are created once, then kept).
503
+ await import('./roles.js')
504
+ .then(rolesService => registerPluginAccess(manifest.name, rolesService))
505
+ .catch(err => fastify.log.warn(`[plugins] Access setup for "${manifest.name}" failed: ${err.message}`));
506
+
443
507
  // Detect optional plugin.public.js for root-level route registration
444
508
  _loadedPlugins[manifest.name] = { enabled: true, publicEntry: null };
445
509
  const publicEntryPath = path.join(PLUGINS_DIR, manifest.name, 'plugin.public.js');
@@ -634,38 +698,240 @@ export async function setupPlugin(pluginName, services) {
634
698
  }
635
699
  }
636
700
 
637
- // Roles - read plugins/<name>/roles/*.json, register resources + persist roles
701
+ // Roles and permissions - see registerPluginAccess()
638
702
  if (services.roles) {
639
- const rolesDir = path.join(pluginDir, 'roles');
640
- let roleFiles;
641
- try {
642
- roleFiles = await fs.readdir(rolesDir);
643
- } catch {
644
- roleFiles = [];
703
+ result.roles.push(...(await registerPluginAccess(pluginName, services.roles)).roles);
704
+ }
705
+
706
+ return result;
707
+ }
708
+
709
+ /**
710
+ * Register what a plugin declares about access: the permission resources it
711
+ * guards its own routes with, the roles it brings, and which of the site's
712
+ * existing roles should start out holding its permissions.
713
+ *
714
+ * Two places declare these, and both are read:
715
+ *
716
+ * plugin.json "permissions": [{key, label?, actions?, group?, grant?}]
717
+ * roles/*.json {name, label, level, permissions, badgeClass?, resources?: [...]}
718
+ *
719
+ * A resource's `grant: ["admin"]` gives those roles the permission once; a
720
+ * role file creates its role once. After that both belong to the admin: an
721
+ * edited role is never overwritten and a revoked grant is never re-added.
722
+ *
723
+ * Resources live in memory, so this runs on every boot for each enabled
724
+ * plugin as well as on enable - otherwise a plugin's permissions vanish from
725
+ * the role editor after the first restart.
726
+ *
727
+ * @param {string} pluginName
728
+ * @param {object} rolesService - server/services/roles.js (injectable for tests)
729
+ * @returns {Promise<{roles: string[], resources: string[]}>}
730
+ */
731
+ export async function registerPluginAccess(pluginName, rolesService) {
732
+ const out = {roles: [], resources: []};
733
+ const pluginDir = path.join(PLUGINS_DIR, pluginName);
734
+ const grants = [];
735
+
736
+ const addResource = (resource) => {
737
+ if (!resource || typeof resource.key !== 'string' || !resource.key) return;
738
+ const {grant, ...definition} = resource;
739
+ registerPluginResource(definition, pluginName);
740
+ out.resources.push(resource.key);
741
+ for (const role of Array.isArray(grant) ? grant : []) {
742
+ if (typeof role === 'string' && role) grants.push([role, resource.key]);
645
743
  }
646
- for (const file of roleFiles.filter(f => f.endsWith('.json'))) {
647
- try {
648
- const raw = await fs.readFile(path.join(rolesDir, file), 'utf8');
649
- const def = JSON.parse(raw);
650
-
651
- // Register any declared resources into the permission overlay
652
- if (Array.isArray(def.resources)) {
653
- for (const resource of def.resources) {
654
- registerPluginResource(resource, pluginName);
655
- }
656
- }
744
+ };
657
745
 
658
- // Persist the role (idempotent)
659
- const {resources: _resources, ...roleData} = def;
660
- await services.roles.createRole({...roleData, plugin: pluginName});
661
- result.roles.push(def.name);
662
- } catch {
663
- // Skip missing or invalid role files
746
+ try {
747
+ const manifest = JSON.parse(await fs.readFile(path.join(pluginDir, 'plugin.json'), 'utf8'));
748
+ if (Array.isArray(manifest.permissions)) manifest.permissions.forEach(addResource);
749
+ } catch { /* no manifest permissions */ }
750
+
751
+ let roleFiles = [];
752
+ try {
753
+ roleFiles = (await fs.readdir(path.join(pluginDir, 'roles'))).filter(f => f.endsWith('.json'));
754
+ } catch { /* no roles dir */ }
755
+
756
+ const roleDefs = [];
757
+ for (const file of roleFiles) {
758
+ try {
759
+ const def = JSON.parse(await fs.readFile(path.join(pluginDir, 'roles', file), 'utf8'));
760
+ if (Array.isArray(def.resources)) def.resources.forEach(addResource);
761
+ if (typeof def.name === 'string' && def.name) roleDefs.push(def);
762
+ } catch { /* skip an unreadable role file */ }
763
+ }
764
+
765
+ for (const def of roleDefs) {
766
+ const {resources: _resources, ...roleData} = def;
767
+ if (validateRoleDef(roleData)) continue;
768
+ try {
769
+ const ensure = rolesService.ensureRole || rolesService.createRole;
770
+ await ensure({...roleData, plugin: pluginName});
771
+ out.roles.push(def.name);
772
+ } catch { /* skip */ }
773
+ }
774
+
775
+ for (const [role, permission] of grants) {
776
+ try {
777
+ await rolesService.grantPermissionOnce?.(role, permission, pluginName);
778
+ } catch { /* a role that does not exist is simply not granted */ }
779
+ }
780
+
781
+ // Bare permissions ('blog-posts') expand to the resource's actions when
782
+ // the cache is built, which happened before these resources existed.
783
+ if (out.resources.length) await rolesService.invalidate?.();
784
+ return out;
785
+ }
786
+
787
+ /** Role names: what a role slug has always looked like in the roles collection. */
788
+ const ROLE_NAME_RE = /^[a-z0-9][a-z0-9-_]{0,63}$/;
789
+
790
+ /**
791
+ * Why a role definition is unusable, or null if it is fine. Level 0 is the
792
+ * root role; a plugin can never mint another one.
793
+ *
794
+ * @param {object} def
795
+ * @returns {string|null}
796
+ */
797
+ function validateRoleDef(def) {
798
+ if (!def || typeof def !== 'object') return 'a role definition object is required';
799
+ if (typeof def.name !== 'string' || !ROLE_NAME_RE.test(def.name)) {
800
+ return 'name must be lowercase letters, digits, "-" or "_" (max 64)';
801
+ }
802
+ if (!Number.isInteger(def.level) || def.level < 1) return 'level must be a whole number of 1 or more';
803
+ if (def.label != null && typeof def.label !== 'string') return 'label must be a string';
804
+ if (def.permissions != null && !(Array.isArray(def.permissions) && def.permissions.every(p => typeof p === 'string'))) {
805
+ return 'permissions must be an array of strings';
806
+ }
807
+ if (def.admin != null && !normaliseAdminScope(def.admin)) {
808
+ return 'admin must be {home?, allow: ["/route", ...]} with at least one /-rooted route';
809
+ }
810
+ return null;
811
+ }
812
+
813
+ /**
814
+ * Register a role on behalf of a plugin - the code behind `hooks.registerRole()`.
815
+ *
816
+ * Same rules as a `roles/*.json` file: created once, then the admin's. Calling
817
+ * it again (every boot, say) is a no-op for a role this plugin already owns,
818
+ * so an admin's edits survive. A name held by the site or by another plugin is
819
+ * refused with an error rather than skipped - a plugin asking for `admin`
820
+ * should find out, not silently share the site's role.
821
+ *
822
+ * `resources` (permission definitions, as in plugin.json `permissions`) are
823
+ * registered too, `grant` included.
824
+ *
825
+ * @param {string} pluginName
826
+ * @param {{name:string,label?:string,level:number,permissions?:string[],badgeClass?:string,resources?:object[]}} def
827
+ * @param {object} [rolesService]
828
+ * @returns {Promise<{name: string, created: boolean}>}
829
+ */
830
+ export async function registerRoleForPlugin(pluginName, def, rolesService = defaultRolesService) {
831
+ if (!def || typeof def !== 'object') throw new Error('registerRole: a role definition object is required');
832
+ const {resources, ...roleData} = def;
833
+ const problem = validateRoleDef(roleData);
834
+ if (problem) throw new Error(`registerRole: ${problem}`);
835
+
836
+ const existing = await rolesService.getRoleData(roleData.name);
837
+ if (existing && existing.plugin !== pluginName) {
838
+ throw new Error(`registerRole: role "${roleData.name}" already exists and does not belong to plugin "${pluginName}"`);
839
+ }
840
+
841
+ let registered = false;
842
+ for (const resource of Array.isArray(resources) ? resources : []) {
843
+ if (!resource || typeof resource.key !== 'string' || !resource.key) continue;
844
+ const {grant, ...definition} = resource;
845
+ registerPluginResource(definition, pluginName);
846
+ registered = true;
847
+ for (const role of Array.isArray(grant) ? grant : []) {
848
+ if (typeof role === 'string' && role) {
849
+ await rolesService.grantPermissionOnce(role, resource.key, pluginName).catch(() => {});
664
850
  }
665
851
  }
666
852
  }
667
853
 
668
- return result;
854
+ const created = existing ? false : await rolesService.ensureRole({
855
+ label: roleData.name, permissions: [], ...roleData, plugin: pluginName
856
+ });
857
+ if (registered) await rolesService.invalidate();
858
+ return {name: roleData.name, created: !!created};
859
+ }
860
+
861
+ /**
862
+ * Remove a role a plugin registered - the code behind `hooks.unregisterRole()`.
863
+ *
864
+ * Only the owning plugin can remove its role; the site's roles and other
865
+ * plugins' roles are refused. Users are moved off the role in the same step:
866
+ * one holding it as their primary role becomes `user` (the base role that
867
+ * always exists), and it is dropped from everyone's additional roles. That is
868
+ * what the next boot would do anyway (seed() demotes unknown roles), done now
869
+ * so nobody is left holding a role that grants nothing.
870
+ *
871
+ * @param {string} pluginName
872
+ * @param {string} name
873
+ * @param {{rolesService?: object, usersService?: object}} [services]
874
+ * @returns {Promise<{name: string, removed: boolean, usersReassigned: number}>}
875
+ */
876
+ export async function unregisterRoleForPlugin(pluginName, name, {
877
+ rolesService = defaultRolesService,
878
+ usersService = defaultUsersService
879
+ } = {}) {
880
+ const existing = await rolesService.getRoleData(name);
881
+ if (!existing) return {name, removed: false, usersReassigned: 0};
882
+ if (existing.plugin !== pluginName) {
883
+ throw new Error(`unregisterRole: role "${name}" does not belong to plugin "${pluginName}"`);
884
+ }
885
+
886
+ await rolesService.removeRole(name);
887
+
888
+ let usersReassigned = 0;
889
+ for (const user of await usersService.listUsers()) {
890
+ const additional = Array.isArray(user.additionalRoles) ? user.additionalRoles : [];
891
+ const holdsPrimary = user.role === name;
892
+ if (!holdsPrimary && !additional.includes(name)) continue;
893
+ await usersService.updateUser(user.id, {
894
+ ...(holdsPrimary && {role: 'user'}),
895
+ additionalRoles: additional.filter(r => r !== name)
896
+ });
897
+ usersReassigned++;
898
+ }
899
+ return {name, removed: true, usersReassigned};
900
+ }
901
+
902
+ /**
903
+ * Remove every role a plugin owns - used when the plugin is uninstalled.
904
+ * (Disabling keeps them; see teardownPlugin.)
905
+ *
906
+ * @param {string} pluginName
907
+ * @param {{rolesService?: object, usersService?: object}} [services]
908
+ * @returns {Promise<string[]>} the removed role names
909
+ */
910
+ export async function unregisterAllRolesForPlugin(pluginName, services = {}) {
911
+ const rolesService = services.rolesService || defaultRolesService;
912
+ const owned = [...rolesService.getRoleMap().keys()];
913
+ const removed = [];
914
+ for (const name of owned) {
915
+ const data = await rolesService.getRoleData(name);
916
+ if (data?.plugin !== pluginName) continue;
917
+ const res = await unregisterRoleForPlugin(pluginName, name, services);
918
+ if (res.removed) removed.push(name);
919
+ }
920
+ return removed;
921
+ }
922
+
923
+ /**
924
+ * The role hooks handed to one plugin, bound to its name so it can only ever
925
+ * register and remove roles of its own.
926
+ *
927
+ * @param {string} pluginName
928
+ * @returns {{registerRole: Function, unregisterRole: Function}}
929
+ */
930
+ export function pluginRoleHooks(pluginName) {
931
+ return {
932
+ registerRole: (def) => registerRoleForPlugin(pluginName, def),
933
+ unregisterRole: (name) => unregisterRoleForPlugin(pluginName, name)
934
+ };
669
935
  }
670
936
 
671
937
  /**
@@ -714,16 +980,11 @@ export async function teardownPlugin(pluginName, services, fastify) {
714
980
  }
715
981
  }
716
982
 
717
- // Roles - remove plugin-contributed roles and unregister their resources
718
- if (services.roles) {
719
- try {
720
- await services.roles.removeRolesByPlugin(pluginName);
721
- unregisterPluginResourcesByPlugin(pluginName);
722
- result.roles.push(pluginName);
723
- } catch (err) {
724
- fastify.log.warn(`[plugins] Could not remove roles for "${pluginName}": ${err.message}`);
725
- }
726
- }
983
+ // Permissions - unregister the plugin's resources. Its roles are KEPT: a
984
+ // role removed here would leave every user holding it with a role the
985
+ // next boot's migrateUserRoles() rewrites to 'user', so a disable/enable
986
+ // cycle would silently demote them. Like collections, they stay put.
987
+ unregisterPluginResourcesByPlugin(pluginName);
727
988
 
728
989
  if (_loadedPlugins[pluginName]) {
729
990
  _loadedPlugins[pluginName].enabled = false;
@@ -797,11 +1058,10 @@ export async function runLifecycleHook(name, hook, fastify) {
797
1058
  const torn = await teardownPlugin(name, services, fastify);
798
1059
  if (torn.pages.length) fastify.log.info(`[plugins] Removed pages for "${name}": ${torn.pages.join(', ')}`);
799
1060
  if (torn.forms.length) fastify.log.info(`[plugins] Removed forms for "${name}": ${torn.forms.join(', ')}`);
800
- if (torn.roles.length) fastify.log.info(`[plugins] Removed roles for "${name}"`);
801
1061
  }
802
1062
 
803
1063
  if (typeof mod[hook] === 'function') {
804
- await mod[hook]({ fastify, services });
1064
+ await mod[hook]({ fastify, services, hooks: pluginRoleHooks(name) });
805
1065
  }
806
1066
  } catch (err) {
807
1067
  fastify.log.error(`Plugin "${name}" lifecycle hook "${hook}" failed: ${err.message}`);
@@ -908,6 +1168,33 @@ export function pluginExpiry(manifest, given) {
908
1168
  : null;
909
1169
  }
910
1170
 
1171
+
1172
+ /**
1173
+ * The sidebar name of the screen a view draws, for its banner - '' when the
1174
+ * plugin has one screen or the view has no sidebar entry of its own.
1175
+ *
1176
+ * A route maps a path to a view and a sidebar entry links to a path, so the
1177
+ * entry whose link is one of the view's routes names it. Sub-entries win over
1178
+ * the top-level one: Site Manager's top entry and its "Sites" entry share a
1179
+ * link, and "Sites" is the screen.
1180
+ *
1181
+ * @param {object} manifest
1182
+ * @param {string} viewName
1183
+ * @returns {string}
1184
+ */
1185
+ export function viewSection(manifest, viewName) {
1186
+ const admin = manifest.admin || {};
1187
+ const paths = new Set((admin.routes || [])
1188
+ .filter(r => r && r.view === viewName && typeof r.path === 'string')
1189
+ .map(r => `#${r.path}`));
1190
+ if (!paths.size) return '';
1191
+ const top = admin.sidebar || [];
1192
+ const entries = [...top.flatMap(e => (e && e.items) || []), ...top];
1193
+ const hit = entries.find(e => e && paths.has(e.url));
1194
+ const text = hit ? String(hit.text || '').trim() : '';
1195
+ return text && text !== (manifest.displayName || manifest.name) ? text : '';
1196
+ }
1197
+
911
1198
  export async function getAdminPluginConfig() {
912
1199
  const manifests = await discoverPlugins();
913
1200
  const states = getPluginStates();
@@ -955,6 +1242,7 @@ export async function getAdminPluginConfig() {
955
1242
  meta[viewName] = {
956
1243
  plugin: manifest.name,
957
1244
  displayName: manifest.displayName || manifest.name,
1245
+ section: viewSection(manifest, viewName),
958
1246
  version: manifest.version || '1.0.0',
959
1247
  date: manifest.date || '',
960
1248
  author: manifest.author || '',
@@ -984,5 +1272,5 @@ export async function getAdminPluginConfig() {
984
1272
  }
985
1273
  }
986
1274
 
987
- return { sidebar, routes, views, css, meta };
1275
+ return { sidebar, routes, views, css, meta, takeovers: getToolTakeovers() };
988
1276
  }