domma-cms 0.72.0 → 0.74.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/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/admin-theme.js +1 -0
- package/admin/js/lib/projects-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/templates/projects.html +69 -10
- package/admin/js/templates/theme.html +0 -3
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/projects.js +58 -3
- package/admin/js/views/theme.js +1 -1
- package/package.json +1 -1
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -1
- package/server/routes/api/projects.js +16 -2
- package/server/routes/api/theme.js +22 -3
- package/server/server.js +4 -0
- package/server/services/plugins.js +4 -0
- package/server/services/projects.js +86 -26
- package/server/services/renderer.js +24 -10
- package/server/services/themeSettings.js +210 -10
- package/server/templates/page.html +13 -4
- package/admin/js/templates/project-editor.html +0 -45
- package/admin/js/views/project-editor.js +0 -1
|
@@ -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,
|
|
@@ -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,45 +0,0 @@
|
|
|
1
|
-
<div class="page-header">
|
|
2
|
-
<h2><span data-icon="folder"></span> <span id="pe-title">Edit project</span></h2>
|
|
3
|
-
<div class="header-actions">
|
|
4
|
-
<a class="btn btn-secondary" href="#/projects"><span data-icon="arrow-left"></span> Back</a>
|
|
5
|
-
<button class="btn btn-primary" id="pe-save"><span data-icon="check"></span> Save</button>
|
|
6
|
-
</div>
|
|
7
|
-
</div>
|
|
8
|
-
|
|
9
|
-
<div class="card">
|
|
10
|
-
<div class="card-header">
|
|
11
|
-
<span data-icon="info"></span> Project details
|
|
12
|
-
</div>
|
|
13
|
-
<div class="card-body">
|
|
14
|
-
<div class="grid grid-cols-2 dm-pe-grid">
|
|
15
|
-
<div class="form-group">
|
|
16
|
-
<label for="pe-slug">Slug <span class="required">*</span></label>
|
|
17
|
-
<input type="text" id="pe-slug" name="slug" class="form-input" required>
|
|
18
|
-
<small class="form-hint">Lowercase alphanumeric + hyphen; immutable after create</small>
|
|
19
|
-
</div>
|
|
20
|
-
<div class="form-group">
|
|
21
|
-
<label for="pe-name">Name <span class="required">*</span></label>
|
|
22
|
-
<input type="text" id="pe-name" name="name" class="form-input" required>
|
|
23
|
-
</div>
|
|
24
|
-
<div class="form-group col-span-2">
|
|
25
|
-
<label for="pe-description">Description</label>
|
|
26
|
-
<textarea id="pe-description" name="description" class="form-input" rows="2"></textarea>
|
|
27
|
-
</div>
|
|
28
|
-
<div class="form-group">
|
|
29
|
-
<label for="pe-icon">Icon</label>
|
|
30
|
-
<div id="pe-icon-mount"></div>
|
|
31
|
-
<small class="form-hint">Click <code>⊞</code> to browse Domma icons; defaults to <code>folder</code></small>
|
|
32
|
-
</div>
|
|
33
|
-
<div class="form-group">
|
|
34
|
-
<label for="pe-rootUrl">Root URL</label>
|
|
35
|
-
<input type="text" id="pe-rootUrl" name="rootUrl" class="form-input" placeholder="/members">
|
|
36
|
-
<small class="form-hint">URL prefix for auto-inheritance (must start with /)</small>
|
|
37
|
-
</div>
|
|
38
|
-
<div class="form-group">
|
|
39
|
-
<label for="pe-sortOrder">Sort order</label>
|
|
40
|
-
<input type="number" id="pe-sortOrder" name="sortOrder" class="form-input" value="0">
|
|
41
|
-
<small class="form-hint">Lower = higher in the sidebar</small>
|
|
42
|
-
</div>
|
|
43
|
-
</div>
|
|
44
|
-
</div>
|
|
45
|
-
</div>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{api as d}from"../api.js";import{makeIconInput as h}from"../lib/shortcode-modal.js";export const projectEditorView={templateUrl:"/admin/js/templates/project-editor.html",async onMount(t){const p=location.hash.match(/^#\/projects\/edit\/(.+)$/),i=p?decodeURIComponent(p[1]):null,s=!i;let e={slug:"",name:"",description:"",icon:"folder",rootUrl:"",sortOrder:0};if(!s){const o=E.loader(t.get(0),{type:"dots"});try{e=await d.projects.get(i)}catch(r){E.toast(`Failed to load project: ${r.message||r}`,{type:"error"}),location.hash="#/projects";return}finally{o.destroy()}}t.find("#pe-title").text(s?"New project":`Edit "${e.slug}"`);const l=t.find("#pe-slug"),n=t.find("#pe-name"),m=t.find("#pe-description"),u=t.find("#pe-rootUrl"),f=t.find("#pe-sortOrder");l.val(e.slug||""),n.val(e.name||""),m.val(e.description||""),u.val(e.rootUrl||""),f.val(e.sortOrder??0),s||l.prop("disabled",!0);const g=t.find("#pe-icon-mount").get(0),a=h("e.g. folder, users, box",e.icon||"folder");a.input.id="pe-icon",a.input.classList.add("form-input"),g.appendChild(a.el),Domma.icons.scan(t.get(0)),t.find("#pe-save").on("click",async()=>{const o={slug:l.val().trim(),name:n.val().trim(),description:m.val().trim(),icon:a.input.value.trim()||"folder",sortOrder:Number.parseInt(f.val(),10)||0},r=u.val().trim();r&&(o.rootUrl=r);try{if(s)await d.projects.create(o);else{const{slug:c,...v}=o;await d.projects.update(i,v)}E.toast("Saved.",{type:"success"}),location.hash="#/projects"}catch(c){E.toast(`Save failed: ${c.message||c}`,{type:"error"})}})}};
|