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.
- package/CLAUDE.md +7 -0
- 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 +4 -4
- package/admin/js/lib/activity-throbber.js +1 -0
- package/admin/js/lib/admin-theme.js +1 -0
- package/admin/js/lib/menu-rows.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/settings.html +8 -0
- package/admin/js/templates/theme.html +0 -3
- package/admin/js/views/menu-editor.js +16 -16
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -1
- package/package.json +2 -2
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -1
- package/server/routes/api/auth.js +16 -3
- package/server/routes/api/settings.js +9 -0
- package/server/routes/api/sidebar.js +7 -2
- package/server/routes/api/theme.js +22 -3
- package/server/server.js +10 -8
- package/server/services/adminHome.js +87 -0
- package/server/services/hooks.js +30 -6
- package/server/services/menus.js +36 -6
- package/server/services/plugins.js +93 -6
- package/server/services/renderer.js +24 -10
- package/server/services/roles.js +14 -3
- package/server/services/sidebar-migration.js +82 -8
- package/server/services/themeSettings.js +210 -10
- package/server/templates/page.html +13 -4
- package/admin/js/lib/sidebar-grouping.test.js +0 -1
|
@@ -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,
|
|
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
|
-
//
|
|
432
|
-
// can
|
|
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 = '
|
|
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
|
-
|
|
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)
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
const customThemeClass =
|
|
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,
|
package/server/services/roles.js
CHANGED
|
@@ -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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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,
|
|
7
|
-
* and patches config/menu-locations.json to map the
|
|
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
|
+
}
|