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
|
@@ -77,6 +77,8 @@ export function defaultThemeConfig() {
|
|
|
77
77
|
theme: DEFAULT_THEME,
|
|
78
78
|
adminTheme: DEFAULT_THEME,
|
|
79
79
|
baseTheme: null,
|
|
80
|
+
// Base of each custom theme the config uses - see rememberCustomBases().
|
|
81
|
+
customBases: {},
|
|
80
82
|
autoTheme: {
|
|
81
83
|
enabled: false,
|
|
82
84
|
dayTheme: 'charcoal-light',
|
|
@@ -174,6 +176,15 @@ export function listThemeTokens() {
|
|
|
174
176
|
*/
|
|
175
177
|
export function getThemeDeclarations(themeId) {
|
|
176
178
|
if (!themeId || !/^[a-z0-9-]+$/.test(themeId)) return null;
|
|
179
|
+
// A custom theme is its base with some tokens changed - adopted onto a
|
|
180
|
+
// region, it has to carry the whole set, not just the changes.
|
|
181
|
+
if (!listThemeIds().includes(themeId)) {
|
|
182
|
+
const custom = getCustomTheme(themeId);
|
|
183
|
+
if (!custom) return null;
|
|
184
|
+
const base = getThemeDeclarations(custom.baseTheme) || '';
|
|
185
|
+
const changed = Object.entries(custom.tokens).map(([k, v]) => `${k}: ${v};`).join('\n');
|
|
186
|
+
return [base, changed].filter(Boolean).join('\n');
|
|
187
|
+
}
|
|
177
188
|
const css = readThemeStylesheet();
|
|
178
189
|
// Anchored so `.dm-theme-x .child {}` descendant rules cannot be mistaken
|
|
179
190
|
// for the theme's own block, and tolerant of the whitespace a rebuild or a
|
|
@@ -214,6 +225,144 @@ export function getThemeTokenMap(themeId) {
|
|
|
214
225
|
return tokens;
|
|
215
226
|
}
|
|
216
227
|
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Custom themes (plugins - the Theme Roller)
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
//
|
|
232
|
+
// A custom theme is a built-in base plus the tokens it changes. It is not a
|
|
233
|
+
// class Domma's engine knows - `Domma.theme.set()` refuses an unknown id and
|
|
234
|
+
// strips every `dm-theme-*` class it did not put there - so it is applied as
|
|
235
|
+
// TWO classes: the base's `dm-theme-<base>`, which Domma owns, and
|
|
236
|
+
// `dm-custom-<id>`, which it leaves alone. The custom class's rule carries only
|
|
237
|
+
// the changed tokens and is emitted after Domma's theme stylesheet, so it wins
|
|
238
|
+
// on source order at equal specificity.
|
|
239
|
+
//
|
|
240
|
+
// Providers are functions, asked on every read: a plugin's themes change while
|
|
241
|
+
// the site runs (save, rename, delete), and a registry snapshot would go stale.
|
|
242
|
+
// A provider must be cheap and synchronous - the render path calls it.
|
|
243
|
+
|
|
244
|
+
const CUSTOM_ID_RE = /^[a-z0-9][a-z0-9-]{0,59}$/;
|
|
245
|
+
const _themeProviders = new Map();
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Register a source of custom themes. One per owner (a plugin's name); a second
|
|
249
|
+
* registration replaces the first, so a plugin that re-registers on reload does
|
|
250
|
+
* not double its themes.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} owner
|
|
253
|
+
* @param {() => Array<{id: string, label?: string, baseTheme: string, tokens: Object<string,string>}>} provider
|
|
254
|
+
*/
|
|
255
|
+
export function registerThemeProvider(owner, provider) {
|
|
256
|
+
if (typeof owner !== 'string' || !owner) throw new Error('registerThemeProvider: owner is required');
|
|
257
|
+
if (typeof provider !== 'function') throw new Error('registerThemeProvider: provider must be a function');
|
|
258
|
+
_themeProviders.set(owner, provider);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** @param {string} owner */
|
|
262
|
+
export function unregisterThemeProvider(owner) {
|
|
263
|
+
_themeProviders.delete(owner);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The class a custom theme adds beside its base's `dm-theme-<base>`. */
|
|
267
|
+
export function customThemeClass(id) {
|
|
268
|
+
return `dm-custom-${id}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Every custom theme the providers currently offer, cleaned: an id that is not
|
|
273
|
+
* a slug, that shadows a built-in theme or that another provider already took
|
|
274
|
+
* is dropped; an unknown base falls back to the default; only real `--dm-*`
|
|
275
|
+
* tokens with plain values survive.
|
|
276
|
+
*
|
|
277
|
+
* @returns {Array<{id: string, label: string, baseTheme: string, tokens: Object<string,string>, owner: string}>}
|
|
278
|
+
*/
|
|
279
|
+
export function listCustomThemes() {
|
|
280
|
+
const builtins = new Set(listThemeIds());
|
|
281
|
+
const out = [];
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
for (const [owner, provider] of _themeProviders) {
|
|
284
|
+
let list;
|
|
285
|
+
try { list = provider(); } catch { continue; }
|
|
286
|
+
for (const t of Array.isArray(list) ? list : []) {
|
|
287
|
+
const id = typeof t?.id === 'string' ? t.id.trim().toLowerCase() : '';
|
|
288
|
+
if (!CUSTOM_ID_RE.test(id) || builtins.has(id) || seen.has(id)) continue;
|
|
289
|
+
const tokens = {};
|
|
290
|
+
for (const [k, v] of Object.entries(t.tokens || {})) {
|
|
291
|
+
if (TOKEN_RE.test(k) && typeof v === 'string' && v.trim() && isSafeTokenValue(v.trim())) tokens[k] = v.trim();
|
|
292
|
+
}
|
|
293
|
+
seen.add(id);
|
|
294
|
+
out.push({
|
|
295
|
+
id,
|
|
296
|
+
label: String(t.label || id).slice(0, 80),
|
|
297
|
+
baseTheme: builtins.has(t.baseTheme) ? t.baseTheme : DEFAULT_THEME,
|
|
298
|
+
tokens,
|
|
299
|
+
owner
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** @param {string} id @returns {object|null} */
|
|
307
|
+
export function getCustomTheme(id) {
|
|
308
|
+
return listCustomThemes().find((t) => t.id === id) || null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* How to apply a theme id: the built-in Domma should be told, and the extra
|
|
313
|
+
* class a custom theme adds. A custom theme whose provider has gone (plugin
|
|
314
|
+
* disabled, licence lapsed) falls back to the base remembered when it was
|
|
315
|
+
* chosen - the page keeps its base look rather than breaking.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} id
|
|
318
|
+
* @param {object} [cfg] - a loaded theme config, for `customBases`
|
|
319
|
+
* @returns {{dommaTheme: string, customClass: string, custom: boolean}}
|
|
320
|
+
*/
|
|
321
|
+
export function resolveThemeClasses(id, cfg = {}) {
|
|
322
|
+
if (!id) return {dommaTheme: DEFAULT_THEME, customClass: '', custom: false};
|
|
323
|
+
const builtins = listThemeIds();
|
|
324
|
+
if (builtins.includes(id)) return {dommaTheme: id, customClass: '', custom: false};
|
|
325
|
+
const custom = getCustomTheme(id);
|
|
326
|
+
if (custom) return {dommaTheme: custom.baseTheme, customClass: customThemeClass(id), custom: true};
|
|
327
|
+
const remembered = cfg?.customBases?.[id];
|
|
328
|
+
if (remembered && builtins.includes(remembered)) return {dommaTheme: remembered, customClass: customThemeClass(id), custom: true};
|
|
329
|
+
// The pre-provider Theme Roller stored its base as `baseTheme` beside a custom `theme`.
|
|
330
|
+
if (id === cfg?.theme && cfg?.baseTheme && builtins.includes(cfg.baseTheme)) {
|
|
331
|
+
return {dommaTheme: cfg.baseTheme, customClass: customThemeClass(id), custom: true};
|
|
332
|
+
}
|
|
333
|
+
// Anything else goes to Domma as it always did - it knows themes the
|
|
334
|
+
// stylesheet does not declare (core-light), and falls back on its own.
|
|
335
|
+
return {dommaTheme: id, customClass: '', custom: false};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The stylesheet for custom themes: one `.dm-custom-<id>` rule of the tokens
|
|
340
|
+
* each changes. Emitted after Domma's theme stylesheet (headInjectLate).
|
|
341
|
+
*
|
|
342
|
+
* @param {string[]|null} [ids] - only these (the ones in use); all when null
|
|
343
|
+
* @returns {string} CSS, or '' when there are none
|
|
344
|
+
*/
|
|
345
|
+
export function compileCustomThemeCss(ids = null) {
|
|
346
|
+
const wanted = ids ? new Set(ids.filter(Boolean)) : null;
|
|
347
|
+
return listCustomThemes()
|
|
348
|
+
.filter((t) => Object.keys(t.tokens).length && (!wanted || wanted.has(t.id)))
|
|
349
|
+
.map((t) => `/* ${t.label.replace(/[*/<>]/g, '')} (${t.baseTheme}) */\n.${customThemeClass(t.id)} {\n`
|
|
350
|
+
+ Object.entries(t.tokens).map(([k, v]) => `${k}: ${v};`).join('\n') + '\n}')
|
|
351
|
+
.join('\n\n');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The page-head `<style>` for the custom themes a page can show, or ''. Only
|
|
356
|
+
* those: every theme a Roller keeps is ~250 tokens, and an unused draft's
|
|
357
|
+
* name has no business in public page source.
|
|
358
|
+
*
|
|
359
|
+
* @param {string[]} [ids] - the page's theme, day and night; all when omitted
|
|
360
|
+
*/
|
|
361
|
+
export function buildCustomThemeStyleTag(ids = null) {
|
|
362
|
+
const css = compileCustomThemeCss(ids);
|
|
363
|
+
return css ? `<style id="dm-custom-themes">\n${css.replace(/<\/style>/gi, '<\\/style>')}\n</style>` : '';
|
|
364
|
+
}
|
|
365
|
+
|
|
217
366
|
// ---------------------------------------------------------------------------
|
|
218
367
|
// Load / save / migrate
|
|
219
368
|
// ---------------------------------------------------------------------------
|
|
@@ -300,14 +449,15 @@ export function loadThemeConfigSync() {
|
|
|
300
449
|
* @returns {Promise<object>} The stored config.
|
|
301
450
|
*/
|
|
302
451
|
export async function saveThemeConfig(input) {
|
|
303
|
-
const
|
|
452
|
+
const stored = await loadThemeConfig();
|
|
453
|
+
const errors = validateThemeConfig(input, {remembered: rememberedIds(stored)});
|
|
304
454
|
if (errors.length) {
|
|
305
455
|
const err = new Error(errors.join('; '));
|
|
306
456
|
err.validation = errors;
|
|
307
457
|
throw err;
|
|
308
458
|
}
|
|
309
459
|
|
|
310
|
-
const current =
|
|
460
|
+
const current = stored;
|
|
311
461
|
const cfg = mergeDefaults({
|
|
312
462
|
...current,
|
|
313
463
|
...input,
|
|
@@ -319,6 +469,7 @@ export async function saveThemeConfig(input) {
|
|
|
319
469
|
overrides: Array.isArray(input.overrides) ? input.overrides.map(normaliseOverride) : current.overrides
|
|
320
470
|
});
|
|
321
471
|
cfg.version = 1;
|
|
472
|
+
rememberCustomBases(cfg);
|
|
322
473
|
|
|
323
474
|
await fs.mkdir(CONFIG_DIR, {recursive: true});
|
|
324
475
|
await fs.writeFile(THEME_FILE, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
@@ -327,6 +478,43 @@ export async function saveThemeConfig(input) {
|
|
|
327
478
|
return cfg;
|
|
328
479
|
}
|
|
329
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Record the base of every custom theme the config uses, so a page keeps that
|
|
483
|
+
* base if the theme's plugin goes away (see resolveThemeClasses). `baseTheme`
|
|
484
|
+
* keeps its old meaning - the base of the site theme when that is custom - for
|
|
485
|
+
* the site.json mirror's readers.
|
|
486
|
+
*
|
|
487
|
+
* @param {object} cfg - mutated
|
|
488
|
+
*/
|
|
489
|
+
function rememberCustomBases(cfg) {
|
|
490
|
+
const custom = new Map(listCustomThemes().map((t) => [t.id, t.baseTheme]));
|
|
491
|
+
const builtins = listThemeIds();
|
|
492
|
+
// A pre-provider Theme Roller config: `baseTheme` beside a custom `theme`,
|
|
493
|
+
// no customBases yet. Keep it, or the first save flips the site to default.
|
|
494
|
+
const legacy = {};
|
|
495
|
+
if (cfg.theme && cfg.baseTheme && !builtins.includes(cfg.theme) && builtins.includes(cfg.baseTheme)) {
|
|
496
|
+
legacy[cfg.theme] = cfg.baseTheme;
|
|
497
|
+
}
|
|
498
|
+
const used = [cfg.theme, cfg.adminTheme, cfg.autoTheme?.dayTheme, cfg.autoTheme?.nightTheme,
|
|
499
|
+
...(cfg.overrides || []).map((o) => o?.theme)];
|
|
500
|
+
const bases = {};
|
|
501
|
+
for (const id of used) {
|
|
502
|
+
if (!id) continue;
|
|
503
|
+
if (custom.has(id)) bases[id] = custom.get(id);
|
|
504
|
+
else if (cfg.customBases?.[id]) bases[id] = cfg.customBases[id]; // provider away: keep what we knew
|
|
505
|
+
else if (legacy[id]) bases[id] = legacy[id];
|
|
506
|
+
}
|
|
507
|
+
cfg.customBases = bases;
|
|
508
|
+
cfg.baseTheme = bases[cfg.theme] || null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Custom theme ids a stored config already uses and remembers the base of. */
|
|
512
|
+
export function rememberedIds(cfg) {
|
|
513
|
+
const ids = Object.keys(cfg?.customBases || {});
|
|
514
|
+
if (cfg?.theme && cfg?.baseTheme && !listThemeIds().includes(cfg.theme)) ids.push(cfg.theme);
|
|
515
|
+
return ids;
|
|
516
|
+
}
|
|
517
|
+
|
|
330
518
|
/**
|
|
331
519
|
* Rewrite the legacy site.json keys so third parties that still read them see
|
|
332
520
|
* the current values. One-way: nothing reads these back.
|
|
@@ -415,7 +603,9 @@ function normaliseOverride(o) {
|
|
|
415
603
|
*/
|
|
416
604
|
function isSafeTokenValue(value) {
|
|
417
605
|
if (value.length > 200) return false;
|
|
418
|
-
|
|
606
|
+
// A backslash can spell `url(` as `u\72l(`, and `/*` opens a comment that
|
|
607
|
+
// swallows the next declaration - neither has a legitimate use in a token.
|
|
608
|
+
if (/[{}<>;\\]|\/\*|\*\//.test(value)) return false;
|
|
419
609
|
return !/@import|javascript:|expression\s*\(|url\s*\(/i.test(value);
|
|
420
610
|
}
|
|
421
611
|
|
|
@@ -426,23 +616,33 @@ function isSafeTokenValue(value) {
|
|
|
426
616
|
* @param {object} input
|
|
427
617
|
* @returns {string[]}
|
|
428
618
|
*/
|
|
429
|
-
export function validateThemeConfig(input) {
|
|
619
|
+
export function validateThemeConfig(input, {remembered = []} = {}) {
|
|
430
620
|
const errors = [];
|
|
431
621
|
if (!input || typeof input !== 'object') return ['Theme config must be an object'];
|
|
432
622
|
|
|
433
|
-
const
|
|
434
|
-
|
|
623
|
+
const builtins = listThemeIds();
|
|
624
|
+
// Custom themes (a plugin's) are chosen like any other - except by the
|
|
625
|
+
// public switcher below, which switches through Domma's engine. One the
|
|
626
|
+
// config already uses stays valid after its plugin goes (`remembered` -
|
|
627
|
+
// its customBases): otherwise nothing else on the Theme screen could be
|
|
628
|
+
// saved until every use of it had been re-picked.
|
|
629
|
+
const known = builtins.length
|
|
630
|
+
? [...builtins, ...listCustomThemes().map((t) => t.id), ...remembered]
|
|
631
|
+
: [];
|
|
632
|
+
const checkTheme = (value, label, allowed = known) => {
|
|
435
633
|
if (value === undefined || value === null || value === '') return;
|
|
436
634
|
if (typeof value !== 'string' || !/^[a-z0-9-]+$/.test(value)) {
|
|
437
635
|
errors.push(`${label} is not a valid theme id`);
|
|
438
|
-
} else if (
|
|
439
|
-
errors.push(
|
|
636
|
+
} else if (allowed.length && !allowed.includes(value)) {
|
|
637
|
+
errors.push(allowed === builtins && known.includes(value)
|
|
638
|
+
? `${label}: "${value}" is a custom theme, which the public theme switcher cannot offer`
|
|
639
|
+
: `${label}: unknown theme "${value}"`);
|
|
440
640
|
}
|
|
441
641
|
};
|
|
442
642
|
|
|
443
643
|
checkTheme(input.theme, 'Front-end theme');
|
|
444
644
|
checkTheme(input.adminTheme, 'Admin theme');
|
|
445
|
-
if (input.baseTheme) checkTheme(input.baseTheme, 'Base theme');
|
|
645
|
+
if (input.baseTheme) checkTheme(input.baseTheme, 'Base theme', builtins);
|
|
446
646
|
if (input.autoTheme) {
|
|
447
647
|
checkTheme(input.autoTheme.dayTheme, 'Day theme');
|
|
448
648
|
checkTheme(input.autoTheme.nightTheme, 'Night theme');
|
|
@@ -470,7 +670,7 @@ export function validateThemeConfig(input) {
|
|
|
470
670
|
if (!Array.isArray(sw.themes)) {
|
|
471
671
|
errors.push('switcher.themes must be an array');
|
|
472
672
|
} else {
|
|
473
|
-
for (const id of sw.themes) checkTheme(id, 'switcher.themes');
|
|
673
|
+
for (const id of sw.themes) checkTheme(id, 'switcher.themes', builtins);
|
|
474
674
|
}
|
|
475
675
|
}
|
|
476
676
|
}
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
<!-- Late head injection - custom CSS always loads last so it can override everything -->
|
|
40
40
|
{{headInjectLate}}
|
|
41
41
|
</head>
|
|
42
|
-
<body class="dm-cloaked dm-theme-{{
|
|
42
|
+
<body class="dm-cloaked dm-theme-{{dommaTheme}} {{customThemeClass}} {{layoutBodyClass}}" data-layout="{{layout}}">
|
|
43
43
|
|
|
44
44
|
{{#if showNavbar}}
|
|
45
45
|
<nav id="site-navbar" class="{{navbarClass}}"></nav>
|
|
@@ -109,7 +109,9 @@
|
|
|
109
109
|
window.Domma.init();
|
|
110
110
|
}
|
|
111
111
|
if (window.Domma && window.Domma.theme) {
|
|
112
|
-
|
|
112
|
+
// The built-in theme Domma knows; a custom theme's own class
|
|
113
|
+
// (dm-custom-<id>) is on <body> already and Domma leaves it alone.
|
|
114
|
+
window.Domma.theme.init({ theme: '{{dommaTheme}}', persist: false });
|
|
113
115
|
}
|
|
114
116
|
window.__CMS_NAV__ = {{navJson}};
|
|
115
117
|
window.__CMS_SITE__ = {{siteJson}};
|
|
@@ -121,13 +123,20 @@
|
|
|
121
123
|
var n = new Date(), m = n.getHours() * 60 + n.getMinutes(), ds = (c.dayStart || "07:00").split(":"),
|
|
122
124
|
ns = (c.nightStart || "19:00").split(":"), d = +ds[0] * 60 + (+ds[1] || 0),
|
|
123
125
|
e = +ns[0] * 60 + (+ns[1] || 0), t = (m >= d && m < e) ? c.dayTheme : c.nightTheme;
|
|
124
|
-
|
|
126
|
+
// A custom theme is a base plus its own class (see resolveThemeClasses).
|
|
127
|
+
var a = (c.apply && c.apply[t]) || { theme: t, custom: '' };
|
|
128
|
+
if (window.Domma && window.Domma.theme) window.Domma.theme.set(a.theme);
|
|
129
|
+
var b = document.body;
|
|
130
|
+
if (b) {
|
|
131
|
+
b.className = b.className.split(' ').filter(function (k) { return k.indexOf('dm-custom-') !== 0; }).join(' ');
|
|
132
|
+
if (a.custom) b.classList.add(a.custom);
|
|
133
|
+
}
|
|
125
134
|
}());
|
|
126
135
|
{{dconfigScript}}
|
|
127
136
|
</script>
|
|
128
137
|
|
|
129
138
|
<!-- Site initialisation -->
|
|
130
|
-
<script src="/public/js/site.js?v=
|
|
139
|
+
<script src="/public/js/site.js?v=20260925-custom-themes" type="module"></script>
|
|
131
140
|
<script src="/public/js/collection-context.js?v=20260919-ctx-tokens" type="module"></script>
|
|
132
141
|
{{ctxMenusModule}}
|
|
133
142
|
|
|
@@ -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"])});
|