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.
- package/CLAUDE.md +24 -3
- 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/app.js +5 -5
- package/admin/js/lib/activity-throbber.js +1 -0
- package/admin/js/lib/admin-scope.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/page-editor.html +1 -4
- package/admin/js/templates/role-editor.html +28 -0
- package/admin/js/templates/settings.html +8 -0
- package/admin/js/views/menu-editor.js +16 -16
- package/admin/js/views/page-editor.js +26 -26
- package/admin/js/views/role-editor.js +3 -1
- package/admin/js/views/settings.js +3 -3
- package/package.json +2 -2
- package/plugins/_template/plugin.js +5 -2
- package/plugins/blog/CLAUDE.md +20 -5
- package/plugins/blog/admin/views/kit.js +8 -10
- package/plugins/blog/lib/layouts.js +36 -0
- package/plugins/blog/lib/nav-link.js +22 -0
- package/plugins/blog/lib/page.js +13 -1
- package/plugins/blog/plugin.js +28 -9
- package/plugins/blog/plugin.json +6 -6
- package/plugins/blog/plugin.public.js +102 -80
- package/plugins/blog/tests/layouts.test.js +29 -0
- package/plugins/blog/tests/nav-link.test.js +10 -1
- package/plugins/blog/tests/public.test.js +21 -0
- package/plugins/free-tier.lock.json +13 -13
- package/server/middleware/auth.js +21 -0
- package/server/routes/api/auth.js +29 -8
- package/server/routes/api/collections.js +40 -3
- package/server/routes/api/settings.js +9 -0
- package/server/routes/api/sidebar.js +31 -10
- package/server/routes/public.js +13 -17
- 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/permissionRegistry.js +10 -7
- package/server/services/pluginInstaller.js +23 -9
- package/server/services/plugins.js +333 -45
- package/server/services/roles.js +181 -5
- package/server/services/sidebar-migration.js +82 -8
- package/admin/js/lib/sidebar-grouping.test.js +0 -1
package/server/services/roles.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Auth middleware calls getRoleMap() / getPermissionsFor() at request time.
|
|
5
5
|
*
|
|
6
6
|
* Base roles: super-admin (0), admin (1), user (2).
|
|
7
|
-
* Plugin-contributed roles are tagged with a `plugin` field
|
|
7
|
+
* Plugin-contributed roles are tagged with a `plugin` field. They are created
|
|
8
|
+
* once (`ensureRole`) and then belong to the admin; disabling the plugin leaves
|
|
9
|
+
* them in place so user assignments survive a disable/enable cycle.
|
|
8
10
|
*/
|
|
9
11
|
import fs from 'fs/promises';
|
|
10
12
|
import path from 'path';
|
|
@@ -100,6 +102,9 @@ let permissionsMap = new Map();
|
|
|
100
102
|
/** @type {Map<string,string[]>} role name → raw permissions array */
|
|
101
103
|
let rawPermissionsMap = new Map();
|
|
102
104
|
|
|
105
|
+
/** @type {string[]} names of level-0 roles - they hold every permission, including plugin ones */
|
|
106
|
+
let rootRoles = [];
|
|
107
|
+
|
|
103
108
|
/**
|
|
104
109
|
* Build in-memory maps from an array of data entries.
|
|
105
110
|
* Supports both bare resource names ('pages') and dotted action strings ('pages.read').
|
|
@@ -111,6 +116,7 @@ function buildCache(entries) {
|
|
|
111
116
|
roleMap = new Map();
|
|
112
117
|
permissionsMap = new Map();
|
|
113
118
|
rawPermissionsMap = new Map();
|
|
119
|
+
rootRoles = entries.filter(e => e.data?.level === 0).map(e => e.data.name);
|
|
114
120
|
|
|
115
121
|
const addTo = (key, role) => {
|
|
116
122
|
if (!permissionsMap.has(key)) permissionsMap.set(key, []);
|
|
@@ -119,11 +125,13 @@ function buildCache(entries) {
|
|
|
119
125
|
|
|
120
126
|
for (const entry of entries) {
|
|
121
127
|
const d = entry.data;
|
|
128
|
+
const adminScope = d.level === 0 ? null : normaliseAdminScope(d.admin);
|
|
122
129
|
roleMap.set(d.name, {
|
|
123
130
|
label: d.label,
|
|
124
131
|
level: d.level,
|
|
125
132
|
badgeClass: d.badgeClass || '',
|
|
126
|
-
...(d.meta != null && {meta: d.meta})
|
|
133
|
+
...(d.meta != null && {meta: d.meta}),
|
|
134
|
+
...(adminScope && {admin: adminScope})
|
|
127
135
|
});
|
|
128
136
|
rawPermissionsMap.set(d.name, d.permissions || []);
|
|
129
137
|
for (const perm of (d.permissions || [])) {
|
|
@@ -312,6 +320,152 @@ export async function createRole(data) {
|
|
|
312
320
|
buildCache(entries);
|
|
313
321
|
}
|
|
314
322
|
|
|
323
|
+
/** An admin route: `/`-rooted, hash-router path characters only. */
|
|
324
|
+
const ADMIN_PATH_RE = /^\/[A-Za-z0-9\-_/]*$/;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Validate a role's `admin` block - which admin screens the role is confined
|
|
328
|
+
* to - and return it in canonical form, or null if it is absent or unusable.
|
|
329
|
+
*
|
|
330
|
+
* {"home": "/portal", "allow": ["/portal"]}
|
|
331
|
+
*
|
|
332
|
+
* `allow` lists route prefixes (`/portal` also admits `/portal/orders`).
|
|
333
|
+
* `home` is where a user is sent from anywhere else; it defaults to the first
|
|
334
|
+
* allowed route and is always itself allowed. A block with no usable route is
|
|
335
|
+
* treated as absent - confining someone to nothing would lock them out.
|
|
336
|
+
*
|
|
337
|
+
* @param {*} raw
|
|
338
|
+
* @returns {{home: string, allow: string[]}|null}
|
|
339
|
+
*/
|
|
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()))
|
|
349
|
+
? (p.trim().replace(/\/+$/, '') || '/')
|
|
350
|
+
: null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function normaliseAdminScope(raw) {
|
|
354
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
355
|
+
const clean = normaliseAdminPath;
|
|
356
|
+
const allow = [...new Set((Array.isArray(raw.allow) ? raw.allow : []).map(clean).filter(Boolean))];
|
|
357
|
+
const home = clean(raw.home) || allow[0] || null;
|
|
358
|
+
if (!home) return null;
|
|
359
|
+
if (!allow.includes(home)) allow.unshift(home);
|
|
360
|
+
return {home, allow};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The admin scope for a user holding `roleNames`, or null for unrestricted.
|
|
365
|
+
*
|
|
366
|
+
* A user is confined only when EVERY role they hold is: one unscoped role
|
|
367
|
+
* (an `admin` additional role, say) lifts the restriction, because roles
|
|
368
|
+
* grant, they never take away. The level-0 role is never confined. When
|
|
369
|
+
* confined, the allowed routes are the union across roles and `home` is the
|
|
370
|
+
* first role's (the primary role comes first).
|
|
371
|
+
*
|
|
372
|
+
* @param {string[]} roleNames
|
|
373
|
+
* @returns {{home: string, allow: string[]}|null}
|
|
374
|
+
*/
|
|
375
|
+
export function getAdminScope(roleNames) {
|
|
376
|
+
const scopes = [];
|
|
377
|
+
for (const name of roleNames || []) {
|
|
378
|
+
const role = roleMap.get(name);
|
|
379
|
+
if (!role) continue; // an unknown role grants nothing either way
|
|
380
|
+
if (role.level === 0 || !role.admin) return null;
|
|
381
|
+
scopes.push(role.admin);
|
|
382
|
+
}
|
|
383
|
+
if (!scopes.length) return null;
|
|
384
|
+
return {
|
|
385
|
+
home: scopes[0].home,
|
|
386
|
+
allow: [...new Set(scopes.flatMap(sc => sc.allow))]
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A role's stored data (as saved, including `plugin` and `grantedBy`), or null.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} name
|
|
394
|
+
* @returns {Promise<object|null>}
|
|
395
|
+
*/
|
|
396
|
+
export async function getRoleData(name) {
|
|
397
|
+
try {
|
|
398
|
+
return (await readData()).find(e => e.data?.name === name)?.data ?? null;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Create a role only if no role of that name exists yet.
|
|
406
|
+
*
|
|
407
|
+
* This is what plugins use on every enable and every boot. A role the admin
|
|
408
|
+
* has since edited (label, level, permissions) is left exactly as they left
|
|
409
|
+
* it - the plugin file is the starting point, not a setting that wins.
|
|
410
|
+
*
|
|
411
|
+
* @param {{name:string,label:string,level:number,permissions?:string[],badgeClass?:string,plugin?:string}} data
|
|
412
|
+
* @returns {Promise<boolean>} true if the role was created
|
|
413
|
+
*/
|
|
414
|
+
export async function ensureRole(data) {
|
|
415
|
+
let entries;
|
|
416
|
+
try {
|
|
417
|
+
entries = await readData();
|
|
418
|
+
} catch {
|
|
419
|
+
entries = [];
|
|
420
|
+
}
|
|
421
|
+
if (entries.some(e => e.data?.name === data.name)) return false;
|
|
422
|
+
entries.push(makeEntry({permissions: [], ...data}));
|
|
423
|
+
await writeData(entries);
|
|
424
|
+
buildCache(entries);
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Grant a permission to an existing role ONCE per source.
|
|
430
|
+
*
|
|
431
|
+
* A plugin that wants the site's own roles (say `admin`) to be able to use
|
|
432
|
+
* its screens calls this for each. The grant is recorded on the role under
|
|
433
|
+
* `grantedBy`, so if the admin later removes the permission it stays
|
|
434
|
+
* removed - the plugin does not re-add it on the next boot.
|
|
435
|
+
*
|
|
436
|
+
* @param {string} roleName
|
|
437
|
+
* @param {string} permission - `resource` or `resource.action`
|
|
438
|
+
* @param {string} source - usually the plugin name
|
|
439
|
+
* @returns {Promise<boolean>} true if the role changed
|
|
440
|
+
*/
|
|
441
|
+
export async function grantPermissionOnce(roleName, permission, source) {
|
|
442
|
+
let entries;
|
|
443
|
+
try {
|
|
444
|
+
entries = await readData();
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
const entry = entries.find(e => e.data?.name === roleName);
|
|
449
|
+
if (!entry) return false;
|
|
450
|
+
|
|
451
|
+
const marker = `${source}:${permission}`;
|
|
452
|
+
const grantedBy = Array.isArray(entry.data.grantedBy) ? entry.data.grantedBy : [];
|
|
453
|
+
if (grantedBy.includes(marker)) return false;
|
|
454
|
+
|
|
455
|
+
const perms = entry.data.permissions || [];
|
|
456
|
+
const [resource] = permission.split('.');
|
|
457
|
+
const alreadyHeld = perms.includes(permission) || perms.includes(resource);
|
|
458
|
+
entry.data = {
|
|
459
|
+
...entry.data,
|
|
460
|
+
permissions: alreadyHeld ? perms : [...perms, permission],
|
|
461
|
+
grantedBy: [...grantedBy, marker]
|
|
462
|
+
};
|
|
463
|
+
entry.updatedAt = new Date().toISOString();
|
|
464
|
+
await writeData(entries);
|
|
465
|
+
buildCache(entries);
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
|
|
315
469
|
/**
|
|
316
470
|
* Remove a role entry by name and rebuild the cache.
|
|
317
471
|
* Refuses to remove base roles (level 0 protection is enforced elsewhere;
|
|
@@ -383,9 +537,31 @@ export function getRoleLevel(roleName) {
|
|
|
383
537
|
* @returns {string[]}
|
|
384
538
|
*/
|
|
385
539
|
export function getPermissionsFor(resource, action) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
540
|
+
const key = action ? `${resource}.${action}` : resource;
|
|
541
|
+
const granted = permissionsMap.get(key) ?? [];
|
|
542
|
+
// The root role is seeded with the base registry only, so a resource a
|
|
543
|
+
// plugin registers later would otherwise lock out the one role that is
|
|
544
|
+
// meant to be able to do everything.
|
|
545
|
+
const missingRoot = rootRoles.filter(r => !granted.includes(r));
|
|
546
|
+
return missingRoot.length ? [...granted, ...missingRoot] : granted;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The permissions a set of roles holds between them - the union of each
|
|
551
|
+
* role's raw list, so a user with additional roles sees everything any of
|
|
552
|
+
* them grants. A level-0 role holds every resource in the effective registry.
|
|
553
|
+
*
|
|
554
|
+
* @param {string[]} roleNames
|
|
555
|
+
* @param {string[]} [allResources] - effective registry keys, for the root role
|
|
556
|
+
* @returns {string[]}
|
|
557
|
+
*/
|
|
558
|
+
export function getPermissionsForRoles(roleNames, allResources = RESOURCES) {
|
|
559
|
+
const out = new Set();
|
|
560
|
+
for (const name of roleNames || []) {
|
|
561
|
+
if (rootRoles.includes(name)) allResources.forEach(r => out.add(r));
|
|
562
|
+
for (const perm of rawPermissionsMap.get(name) ?? []) out.add(perm);
|
|
563
|
+
}
|
|
564
|
+
return [...out];
|
|
389
565
|
}
|
|
390
566
|
|
|
391
567
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{test as o}from"node:test";import s from"node:assert/strict";import{groupPluginItems as a,stripItemByUrl as p,insertFoldersBeforeSystem as u,pruneEmptySynthesisedFolders as i,TOOLS_FOLDER_TEXT as m,MANAGE_PLUGINS_URL as d}from"./sidebar-grouping.js";const n=(e,t=null)=>({parent:t,item:e});o("groupPluginItems routes every enabled item to Tools, whatever its core flag",()=>{const{toolsFolder:e}=a([n({text:"Analytics",url:"#/plugins/analytics",core:!0}),n({text:"Todo",url:"#/plugins/todo",core:!1}),n({text:"Notes",url:"#/plugins/notes"})]);s.equal(e.text,m),s.deepEqual(e.items.map(t=>t.text),["Analytics","Todo","Notes"])}),o("Marketplace is a standalone entry, not a folder",()=>{const{managePluginsItem:e}=a([n({text:"Todo",url:"#/plugins/todo"})]);s.equal(e.text,"Marketplace"),s.equal(e.url,d),s.equal(e.permission,"plugins"),s.ok(!("items"in e),"it must not carry a sub-tree")}),o("groupPluginItems omits the Tools folder only when nothing is enabled",()=>{const e=a([n({text:"Todo",url:"#/plugins/todo"})]);s.deepEqual(e.toolsFolder.items.map(l=>l.text),["Todo"]);const t=a([]);s.equal(t.toolsFolder,null),s.equal(t.managePluginsItem.text,"Marketplace")}),o("groupPluginItems keeps explicitly-parented items separate",()=>{const{parented:e,toolsFolder:t}=a([n({text:"Nested",url:"#/x"},"Content"),n({text:"Todo",url:"#/plugins/todo",core:!1})]);s.equal(e.length,1),s.equal(e[0].parent,"Content"),s.deepEqual(t.items.map(l=>l.text),["Todo"])}),o("each call returns its own Marketplace object",()=>{const e=a([]).managePluginsItem,t=a([]).managePluginsItem;s.notEqual(e,t),s.deepEqual(e,t)}),o("stripItemByUrl removes the management link at any depth",()=>{const e=[{text:"Overview",items:[{text:"Dashboard",url:"#/"}]},{text:"System",items:[{text:"Users",url:"#/users"},{text:"Plugins",url:"#/plugins"}]}],l=p(e,"#/plugins").find(r=>r.text==="System");s.deepEqual(l.items.map(r=>r.text),["Users"]),s.equal(e.find(r=>r.text==="System").items.length,2)}),o("insertFoldersBeforeSystem places Tools then Marketplace before System",()=>{const t=u([{text:"Overview"},{text:"Data"},{text:"System"},{text:"Documentation"}],[{text:"Tools",items:[]},{text:"Marketplace",url:"#/plugins"},null]);s.deepEqual(t.map(l=>l.text),["Overview","Data","Tools","Marketplace","System","Documentation"])}),o("insertFoldersBeforeSystem appends when no System/Documentation anchor",()=>{const t=u([{text:"Overview"},{text:"Data"}],[{text:"Marketplace",url:"#/plugins"}]);s.deepEqual(t.map(l=>l.text),["Overview","Data","Marketplace"])}),o("pruneEmptySynthesisedFolders drops an empty Tools folder but keeps built-ins",()=>{const e=[{text:"Overview",items:[]},{text:"Tools",items:[]}];s.deepEqual(i(e).map(t=>t.text),["Overview"])}),o("pruneEmptySynthesisedFolders leaves the standalone Marketplace alone",()=>{const e=[{text:"Tools",items:[{text:"Analytics"}]},{text:"Marketplace",url:"#/plugins"}];s.deepEqual(i(e).map(t=>t.text),["Tools","Marketplace"])});
|