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.
@@ -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 errors = validateThemeConfig(input);
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 = await loadThemeConfig();
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
- if (/[{}<>;]/.test(value)) return false;
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 known = listThemeIds();
434
- const checkTheme = (value, label) => {
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 (known.length && !known.includes(value)) {
439
- errors.push(`${label}: unknown theme "${value}"`);
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-{{theme}} {{layoutBodyClass}}" data-layout="{{layout}}">
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
- window.Domma.theme.init({ theme: '{{theme}}', persist: false });
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
- if (window.Domma && window.Domma.theme) window.Domma.theme.set(t);
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=20260829-motion-toggle" type="module"></script>
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"])});